@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/tui/menu.ts CHANGED
@@ -13,6 +13,7 @@ import { HINTS } from "../../tui/grammar.ts";
13
13
  import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
14
14
  import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
15
15
  import { performanceDetail, performanceNodes } from "./performance-menu.ts";
16
+ import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
16
17
  import type { FaderSpec } from "./fader.ts";
17
18
  import {
18
19
  openingMeterCommand,
@@ -25,6 +26,7 @@ import {
25
26
  automationPoints,
26
27
  automationRange,
27
28
  REVERB_IR_BUILTINS,
29
+ SAMPLE_FIT_MODES,
28
30
  SAMPLE_UNITS,
29
31
  SCORE_LIMITS,
30
32
  isTrackAutomationParameter,
@@ -42,6 +44,9 @@ import {
42
44
  CORE_EFFECTS,
43
45
  EFFECT_NAMES,
44
46
  FX_LANES,
47
+ RIG_PRESETS,
48
+ RIG_STAGES,
49
+ rigPresetOf,
45
50
  effectPresetNames,
46
51
  effectSpec,
47
52
  type EffectName,
@@ -85,6 +90,22 @@ import {
85
90
  SYNTH_SIMPLE,
86
91
  normalizeSynth,
87
92
  } from "../../core/synth.ts";
93
+ import {
94
+ isStringTrack,
95
+ STRING_PARAMS,
96
+ STRING_PRESET_NAMES,
97
+ STRING_PRESETS,
98
+ STRING_SIMPLE_PARAMS,
99
+ stringPresetOf,
100
+ } from "../../core/strings.ts";
101
+ import {
102
+ KEYS_PARAMS,
103
+ KEYS_PRESETS,
104
+ KEYS_SIMPLE,
105
+ PIANO_FAMILIES,
106
+ isPianoFamily,
107
+ resolvedKeys,
108
+ } from "../../core/keys.ts";
88
109
  import type { PickerItem } from "../../tui/app.ts";
89
110
  import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
90
111
  import {
@@ -114,6 +135,12 @@ import {
114
135
  type Tuning,
115
136
  } from "../../core/tuning.ts";
116
137
  import { rootName, tuningStepsText } from "../commands/tuning.ts";
138
+ import {
139
+ granularBrowseNodes,
140
+ granularMenuDetail,
141
+ granularMenuLabel,
142
+ granularMenuNodes,
143
+ } from "./granular-menu.ts";
117
144
  import {
118
145
  ARP_RATES,
119
146
  CHORD_MODES,
@@ -321,7 +348,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
321
348
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
322
349
 
323
350
  /** Nudge for a number spec: linear by its step, or a sixth of an octave. */
324
- function specStep(
351
+ export function specStep(
325
352
  spec: NumberParam,
326
353
  ): (value: number, direction: 1 | -1) => number {
327
354
  if (spec.step === "log")
@@ -354,7 +381,12 @@ function laneLabel(lane: AutomationParameter): string {
354
381
  if (!info) return lane;
355
382
  const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
356
383
  const owner =
357
- info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
384
+ info.effect === "synth" ||
385
+ info.effect === "string" ||
386
+ info.effect === "keys" ||
387
+ info.effect === "modal"
388
+ ? info.effect
389
+ : effectSpec(info.effect).label;
358
390
  return `${owner} ${info.param}${unit}`;
359
391
  }
360
392
 
@@ -486,8 +518,42 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
486
518
  },
487
519
  ]
488
520
  : [];
521
+ const grainLabel = granularMenuLabel(track);
522
+ const granular: MenuNode[] =
523
+ track && grainLabel
524
+ ? [
525
+ {
526
+ kind: "menu",
527
+ id: "granular",
528
+ label: grainLabel,
529
+ detail: granularMenuDetail(track),
530
+ help: "grain clouds: preset, source, position, scan, grain size, pitch, shimmer",
531
+ build: (inner) => granularMenuNodes(focused(inner)),
532
+ },
533
+ ]
534
+ : [];
535
+ const keys: MenuNode[] =
536
+ track && isPianoFamily(track.instrument) && track.keys
537
+ ? [
538
+ {
539
+ kind: "menu",
540
+ id: "keys",
541
+ label: "keys",
542
+ detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
543
+ help: "the modelled piano: preset, touch, hammers, dampers, stretch",
544
+ build: (inner) => {
545
+ const current = focused(inner);
546
+ return current
547
+ ? keysNodes(current, Object.keys(KEYS_PARAMS))
548
+ : [];
549
+ },
550
+ },
551
+ ]
552
+ : [];
489
553
  return [
490
554
  ...parameterNodes(context),
555
+ ...granular,
556
+ ...keys,
491
557
  ...tuning,
492
558
  {
493
559
  kind: "menu",
@@ -903,7 +969,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
903
969
  }
904
970
 
905
971
  function instrumentNode(track: Track): MenuNode {
906
- const options: string[] = [...AVAILABLE_INSTRUMENTS];
972
+ const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
907
973
  if (!options.includes(track.instrument)) options.push(track.instrument);
908
974
  return {
909
975
  kind: "choice",
@@ -956,6 +1022,30 @@ function parameterNodes(context: MenuContext): MenuNode[] {
956
1022
  // A wavetable track also has the synth voice's envelope, filters and FM.
957
1023
  if (isWavetableInstrument(track.instrument))
958
1024
  nodes.push(...wavetableNodes(track, context.projectRoot));
1025
+ if (isStringTrack(track)) {
1026
+ nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
1027
+ nodes.push({
1028
+ kind: "menu",
1029
+ id: "string:advanced",
1030
+ label: "advanced",
1031
+ detail: `all ${Object.keys(STRING_PARAMS).length} params`,
1032
+ help: "every string parameter: exciter, loss, body, buzz, sympathetics",
1033
+ build: (inner) => {
1034
+ const current = focused(inner);
1035
+ return current
1036
+ ? stringNodes(current, Object.keys(STRING_PARAMS), false)
1037
+ : [];
1038
+ },
1039
+ });
1040
+ if (Object.keys(track.string ?? {}).some((key) => key !== "preset"))
1041
+ nodes.push({
1042
+ kind: "action",
1043
+ label: "reset to preset",
1044
+ command: "string reset",
1045
+ help: "drop this track's string overrides, keep the preset",
1046
+ });
1047
+ return nodes;
1048
+ }
959
1049
  if (track.sampler) {
960
1050
  nodes.push({
961
1051
  kind: "info",
@@ -982,6 +1072,22 @@ function parameterNodes(context: MenuContext): MenuNode[] {
982
1072
  label: "add a voice",
983
1073
  value: "/sample <path> [as <voice>]",
984
1074
  });
1075
+ } else if (isPianoFamily(track.instrument) && track.keys) {
1076
+ nodes.push(...keysNodes(track, KEYS_SIMPLE));
1077
+ nodes.push({
1078
+ kind: "menu",
1079
+ id: "keys:all",
1080
+ label: "all piano params",
1081
+ detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
1082
+ help: "every modelled piano parameter",
1083
+ build: (inner) => {
1084
+ const current = focused(inner);
1085
+ return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
1086
+ },
1087
+ });
1088
+ } else if (track.instrument === "modal") {
1089
+ // 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
1090
+ nodes.push(...modalParameterNodes(track));
985
1091
  } else if (!isDrumInstrument(track.instrument)) {
986
1092
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
987
1093
  nodes.push({
@@ -1089,6 +1195,44 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1089
1195
  label: "cut / loopAt",
1090
1196
  value: `/sample set ${voice} cut hats · loopAt 2`,
1091
1197
  },
1198
+ // 0.6 fit: the sample's own tempo, its length in beats, and how it fits.
1199
+ {
1200
+ kind: "number",
1201
+ label: "bpm",
1202
+ value: ref.bpm,
1203
+ start: Math.round(context.score.tempoBpm),
1204
+ off: "off",
1205
+ min: SCORE_LIMITS.minSampleBpm,
1206
+ max: SCORE_LIMITS.maxSampleBpm,
1207
+ step: linear(1, SCORE_LIMITS.minSampleBpm, SCORE_LIMITS.maxSampleBpm),
1208
+ format: num,
1209
+ command: (value) => `/bpm ${num(value)} ${voice}`,
1210
+ reset: `/bpm off ${voice}`,
1211
+ help: SAMPLE_CONTROLS.bpm,
1212
+ },
1213
+ {
1214
+ kind: "choice",
1215
+ label: "fitmode",
1216
+ // off: unset (plays as repitch); auto suggests one from the sound.
1217
+ value: ref.fitmode ?? "off",
1218
+ options: ["off", ...SAMPLE_FIT_MODES, "auto"],
1219
+ command: (option) => `/fitmode ${option} ${voice}`,
1220
+ help: `${SAMPLE_CONTROLS.fitmode} · beats for drums, tones for pads · set bpm or len first`,
1221
+ },
1222
+ {
1223
+ kind: "number",
1224
+ label: "len",
1225
+ value: ref.len,
1226
+ start: 4,
1227
+ off: "off",
1228
+ min: 0.25,
1229
+ max: SCORE_LIMITS.maxSampleLenBeats,
1230
+ step: linear(0.25, 0.25, SCORE_LIMITS.maxSampleLenBeats),
1231
+ format: num,
1232
+ command: (value) => `/len ${num(value)} ${voice}`,
1233
+ reset: `/len off ${voice}`,
1234
+ help: SAMPLE_CONTROLS.len,
1235
+ },
1092
1236
  );
1093
1237
  return nodes;
1094
1238
  }
@@ -1105,6 +1249,57 @@ function matchingSynthPreset(track: Track): string | undefined {
1105
1249
  )?.[0];
1106
1250
  }
1107
1251
 
1252
+ /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1253
+ function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1254
+ const effective = resolvedKeys(track.instrument, track.keys);
1255
+ const nodes: MenuNode[] = [
1256
+ {
1257
+ kind: "choice",
1258
+ label: "preset",
1259
+ value: track.keys?.preset ?? "—",
1260
+ options: Object.keys(KEYS_PRESETS),
1261
+ command: (preset) => `keys preset ${preset}`,
1262
+ help: "a starting piano; every value stays editable",
1263
+ },
1264
+ ];
1265
+ for (const key of params) {
1266
+ const param = KEYS_PARAMS[key]!;
1267
+ const stored = track.keys?.[key];
1268
+ if (param.kind === "number") {
1269
+ const base = effective[key] as number;
1270
+ nodes.push({
1271
+ kind: "number",
1272
+ label: key,
1273
+ value: typeof stored === "number" ? stored : undefined,
1274
+ start: base,
1275
+ off: withUnit(formatParam(param, base), param.unit),
1276
+ min: param.min,
1277
+ max: param.max,
1278
+ step: specStep(param),
1279
+ format: (value) => withUnit(formatParam(param, value), param.unit),
1280
+ command: (value) => `keys ${key} ${formatParam(param, value)}`,
1281
+ reset: `keys ${key} off`,
1282
+ help: param.doc,
1283
+ });
1284
+ } else if (param.kind === "enum")
1285
+ nodes.push({
1286
+ kind: "choice",
1287
+ label: key,
1288
+ value: typeof stored === "string" ? stored : String(effective[key]),
1289
+ options: param.values,
1290
+ command: (option) => `keys ${key} ${option}`,
1291
+ help: param.doc,
1292
+ });
1293
+ }
1294
+ nodes.push({
1295
+ kind: "action",
1296
+ label: "reset to the family",
1297
+ command: "keys reset",
1298
+ help: "clear every override and the preset, and the effects the preset added (keeps the family's own sound)",
1299
+ });
1300
+ return nodes;
1301
+ }
1302
+
1108
1303
  function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
1109
1304
  const nodes: MenuNode[] = [];
1110
1305
  if (keys === SYNTH_SIMPLE)
@@ -1175,6 +1370,57 @@ const SYNTH_LABEL: Readonly<Record<string, string>> = {
1175
1370
  fm: "FM amount",
1176
1371
  };
1177
1372
 
1373
+ /** String preset first (simple view), then one row per parameter. */
1374
+ function stringNodes(
1375
+ track: Track,
1376
+ keys: readonly string[],
1377
+ withPreset = true,
1378
+ ): MenuNode[] {
1379
+ const nodes: MenuNode[] = [];
1380
+ const preset = STRING_PRESETS[stringPresetOf(track.string)]!;
1381
+ if (withPreset)
1382
+ nodes.push({
1383
+ kind: "choice",
1384
+ label: "preset",
1385
+ help: preset.doc,
1386
+ value: stringPresetOf(track.string),
1387
+ options: STRING_PRESET_NAMES,
1388
+ command: (name) => `string preset ${name}`,
1389
+ });
1390
+ for (const key of keys) {
1391
+ const param = STRING_PARAMS[key]!;
1392
+ const own = track.string?.[key];
1393
+ const base = preset.values[key] ?? param.default;
1394
+ if (param.kind === "number")
1395
+ nodes.push({
1396
+ kind: "number",
1397
+ label: param.unit ? `${key} ${param.unit}` : key,
1398
+ help: param.doc,
1399
+ value: typeof own === "number" ? own : undefined,
1400
+ start: typeof base === "number" ? base : param.default,
1401
+ off: formatParam(
1402
+ param,
1403
+ typeof base === "number" ? base : param.default,
1404
+ ),
1405
+ min: param.min,
1406
+ max: param.max,
1407
+ step: specStep(param),
1408
+ format: (value) => formatParam(param, value),
1409
+ command: (value) => `string ${key} ${formatParam(param, value)}`,
1410
+ });
1411
+ else if (param.kind === "enum")
1412
+ nodes.push({
1413
+ kind: "choice",
1414
+ label: key,
1415
+ help: param.doc,
1416
+ value: typeof own === "string" ? own : String(base),
1417
+ options: param.values,
1418
+ command: (option) => `string ${key} ${option}`,
1419
+ });
1420
+ }
1421
+ return nodes;
1422
+ }
1423
+
1178
1424
  /** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
1179
1425
  function synthAdvancedNodes(context: MenuContext): MenuNode[] {
1180
1426
  const track = focused(context);
@@ -1324,10 +1570,38 @@ function effectNodes(context: MenuContext): MenuNode[] {
1324
1570
  build: (inner) => effectParamNodes(inner, effect, false),
1325
1571
  };
1326
1572
  };
1327
- const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
1573
+ const rigStages = RIG_STAGES as readonly string[];
1574
+ const more = EFFECT_NAMES.filter(
1575
+ (effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
1576
+ );
1328
1577
  const moreOn = more.filter((effect) => effectValues(track, effect));
1578
+ // 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
1579
+ const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
1580
+ const rig: MenuNode = {
1581
+ kind: "menu",
1582
+ id: "guitar rig",
1583
+ label: "Guitar rig",
1584
+ help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
1585
+ detail:
1586
+ rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
1587
+ build: (inner) => {
1588
+ const current = focused(inner);
1589
+ return [
1590
+ {
1591
+ kind: "choice",
1592
+ label: "rig",
1593
+ value: rigPresetOf(current?.fx) ?? "—",
1594
+ options: [...Object.keys(RIG_PRESETS), "reset"],
1595
+ command: (name) => `rig ${name}`,
1596
+ help: "a whole rig (stomp, head, cab); every stage stays editable",
1597
+ },
1598
+ ...RIG_STAGES.map(node),
1599
+ ];
1600
+ },
1601
+ };
1329
1602
  return [
1330
1603
  ...CORE_EFFECTS.map(node),
1604
+ rig,
1331
1605
  {
1332
1606
  kind: "menu",
1333
1607
  id: "more effects",
@@ -1495,6 +1769,9 @@ function automationNodes(context: MenuContext): MenuNode[] {
1495
1769
  const info = FX_LANE_INFO.get(lane as FxLane);
1496
1770
  if (info?.effect === "synth")
1497
1771
  return track.synth?.[info.param] !== undefined;
1772
+ if (info?.effect === "string") return track.string !== undefined;
1773
+ if (info?.effect === "keys") return track.keys !== undefined;
1774
+ if (info?.effect === "modal") return track.modal !== undefined;
1498
1775
  return info !== undefined && effectValues(track, info.effect) !== undefined;
1499
1776
  });
1500
1777
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -1676,14 +1953,54 @@ function patternsMenu(): MenuNode {
1676
1953
  };
1677
1954
  }
1678
1955
 
1956
+ const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
1957
+
1679
1958
  function soundNodes(): MenuNode[] {
1680
1959
  return [
1960
+ {
1961
+ kind: "menu",
1962
+ id: "group:keys",
1963
+ label: "Keys",
1964
+ help: "modelled pianos, built in (no download)",
1965
+ detail: Object.keys(KEYS_PRESETS).join(" "),
1966
+ build: () =>
1967
+ Object.entries(KEYS_PRESETS).map(([name, preset]): MenuNode => ({
1968
+ kind: "action",
1969
+ label: `${name.padEnd(10)} ${preset.doc}`,
1970
+ command: `piano ${name}`,
1971
+ help: preset.styles,
1972
+ })),
1973
+ },
1681
1974
  {
1682
1975
  kind: "action",
1683
1976
  label: "wavetable synth basic shapes morph · built-in",
1684
1977
  command: "wt basic",
1685
1978
  help: "switch this track to the morphing wavetable synth",
1686
1979
  },
1980
+ // 0.6 instrument groups, one per lane.
1981
+ malletsMenu(),
1982
+ {
1983
+ kind: "menu",
1984
+ id: "strings",
1985
+ label: "Strings",
1986
+ help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…",
1987
+ detail: `${STRING_PRESET_NAMES.length} plucked · built-in`,
1988
+ build: () =>
1989
+ STRING_PRESET_NAMES.map((name): MenuNode => ({
1990
+ kind: "action",
1991
+ label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
1992
+ command: `string ${name}`,
1993
+ help: STRING_PRESETS[name]!.styles,
1994
+ })),
1995
+ },
1996
+ {
1997
+ kind: "menu",
1998
+ id: "granular-browse",
1999
+ label: "Granular",
2000
+ help: "grain clouds from a built-in synth or this track's own sound",
2001
+ detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2002
+ build: granularBrowseNodes,
2003
+ },
1687
2004
  {
1688
2005
  kind: "menu",
1689
2006
  id: "instruments",
@@ -2372,11 +2689,17 @@ export class EditMenu {
2372
2689
  candidate.kind === "choice" &&
2373
2690
  candidate.label === node.label,
2374
2691
  ) ?? node;
2375
- return live.options.map((option) => ({
2376
- kind: "action",
2377
- label: option === live.value ? `${option} ✓` : option,
2378
- command: live.command(option),
2379
- }));
2692
+ // The current value is shown, not re-run: re-confirming a
2693
+ // legacy `piano` would otherwise re-voice it as the grand.
2694
+ return live.options.map((option): MenuNode =>
2695
+ option === live.value
2696
+ ? { kind: "info", label: `${option} ✓`, value: "current" }
2697
+ : {
2698
+ kind: "action",
2699
+ label: option,
2700
+ command: live.command(option),
2701
+ },
2702
+ );
2380
2703
  },
2381
2704
  });
2382
2705
  return { type: "handled" };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Ctrl-k menu rows for modal percussion (0.6): the "Mallets and bells"
3
+ * group under Sound › browse sounds, and the MODAL_PARAMS rows under
4
+ * Sound › Parameters on a modal track. Every row runs a `modal …` command.
5
+ */
6
+ import type { EnumParam, NumberParam } from "../../core/params.ts";
7
+ import {
8
+ MODAL_INSTRUMENT,
9
+ MODAL_MALLET_NAMES,
10
+ MODAL_PARAMS,
11
+ MODAL_PRESETS,
12
+ MODAL_PRESET_NAMES,
13
+ MODAL_SIMPLE,
14
+ modalSettings,
15
+ } from "../../core/resonators.ts";
16
+ import type { Track } from "../../core/score.ts";
17
+ import { num, specStep, type MenuNode } from "./menu.ts";
18
+
19
+ /** Sound › browse sounds › Mallets and bells: one row per preset. */
20
+ export function malletsMenu(): MenuNode {
21
+ return {
22
+ kind: "menu",
23
+ id: "mallets",
24
+ label: "Mallets and bells",
25
+ detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
26
+ help: "struck bars, bells and bowls on the modal resonator engine",
27
+ build: () =>
28
+ MODAL_PRESET_NAMES.map((name): MenuNode => ({
29
+ kind: "action",
30
+ label: `${name} ${MODAL_PRESETS[name].doc}`,
31
+ command: `modal ${name}`,
32
+ help: MODAL_PRESETS[name].styles,
33
+ })),
34
+ };
35
+ }
36
+
37
+ function numberRow(
38
+ name: string,
39
+ spec: NumberParam,
40
+ value: number,
41
+ fallback: number,
42
+ ): MenuNode {
43
+ return {
44
+ kind: "number",
45
+ label: name,
46
+ value,
47
+ min: spec.min,
48
+ max: spec.max,
49
+ step: specStep(spec),
50
+ format: (v) => (spec.unit ? `${num(v)} ${spec.unit}` : num(v)),
51
+ command: (v) => `modal ${name} ${num(v)}`,
52
+ reset: `modal ${name} off`,
53
+ help: `${spec.doc} · preset ${num(fallback)} · x resets`,
54
+ };
55
+ }
56
+
57
+ /** Sound › Parameters rows on a modal track. */
58
+ export function modalParameterNodes(track: Track): MenuNode[] {
59
+ if (track.instrument !== MODAL_INSTRUMENT) return [];
60
+ const modal = track.modal;
61
+ const preset = MODAL_PRESETS[modal?.preset ?? "marimba"].settings;
62
+ const resolved = modalSettings(modal) as Readonly<
63
+ Record<string, number | string>
64
+ >;
65
+ const row = (name: string): MenuNode | undefined => {
66
+ const spec = MODAL_PARAMS[name]!;
67
+ if (name === "mallet")
68
+ return {
69
+ kind: "choice",
70
+ label: "mallet",
71
+ value: modal?.mallet ?? "preset",
72
+ options: ["preset", ...MODAL_MALLET_NAMES],
73
+ command: (option) =>
74
+ option === "preset" ? "modal mallet off" : `modal mallet ${option}`,
75
+ help: (spec as EnumParam).doc,
76
+ };
77
+ if (spec.kind !== "number") return undefined;
78
+ return numberRow(
79
+ name,
80
+ spec,
81
+ Number(resolved[name]),
82
+ Number(preset[name as keyof typeof preset]),
83
+ );
84
+ };
85
+ const nodes: MenuNode[] = [
86
+ {
87
+ kind: "choice",
88
+ label: "preset",
89
+ value: modal?.preset ?? "marimba",
90
+ options: MODAL_PRESET_NAMES,
91
+ command: (option) => `modal preset ${option}`,
92
+ help: "the instrument: mode table, mallet and ring",
93
+ },
94
+ ];
95
+ for (const name of MODAL_SIMPLE) {
96
+ const node = row(name);
97
+ if (node) nodes.push(node);
98
+ }
99
+ const rest = Object.keys(MODAL_PARAMS).filter(
100
+ (name) => !(MODAL_SIMPLE as readonly string[]).includes(name),
101
+ );
102
+ nodes.push({
103
+ kind: "menu",
104
+ id: "modal:advanced",
105
+ label: "advanced",
106
+ detail: `motor, ombak, buzz, click, strike glide, gain`,
107
+ help: "every modal parameter",
108
+ build: () => rest.flatMap((name) => row(name) ?? []),
109
+ });
110
+ if (modal && Object.keys(modal).some((key) => key !== "preset"))
111
+ nodes.push({
112
+ kind: "action",
113
+ label: "reset to preset",
114
+ command: "modal reset",
115
+ help: "clear overrides, keep the preset",
116
+ });
117
+ return nodes;
118
+ }
@@ -48,11 +48,13 @@ import {
48
48
  transportBarStart,
49
49
  } from "../../core/tempo.ts";
50
50
  import {
51
+ LiveFullRenderer,
51
52
  LiveSynth,
52
53
  MAX_LIVE_NOTE_SECONDS,
53
54
  type LiveNotePcm,
54
55
  } from "../audio/live.ts";
55
56
  import type { SampleBank } from "../audio/samples.ts";
57
+ import { fitting, onFitReady } from "../audio/fit.ts";
56
58
  import {
57
59
  DEFAULT_STRUM,
58
60
  perform,
@@ -91,6 +93,8 @@ export interface LiveEngine {
91
93
  setLeadMs(ms: number | undefined): void;
92
94
  noteOn(id: number, note: LiveNotePcm): number;
93
95
  noteOff(id: number): void;
96
+ /** Frames of voice `id` mixed so far, or undefined once it has ended. */
97
+ voicePosition?(id: number): number | undefined;
94
98
  setClick(click: ClickBus | undefined): void;
95
99
  }
96
100
 
@@ -134,6 +138,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
134
138
  export const DEFAULT_GRID = "1/16";
135
139
  /** Play mode's queue lead: short enough that a key feels immediate. */
136
140
  export const PLAY_LEAD_MS = 60;
141
+ /** Status while a long fitted sample window computes (fit.ts). */
142
+ const FITTING_STATUS = "fitting · the sample plays once ready";
137
143
  export const FLASH_MS = 110;
138
144
 
139
145
  export function gridBeats(label: string): number | undefined {
@@ -221,11 +227,16 @@ export class PlaySession {
221
227
  public countInBars = 1;
222
228
  public grid = DEFAULT_GRID;
223
229
  public status: string | undefined;
230
+ /** Unsubscribes the pending "fit ready" listener (fit.ts). */
231
+ private stopFitWait: (() => void) | undefined;
224
232
  /** Scale-degree layout on (`i`); follows key and tuning changes. */
225
233
  public degreesOn = false;
226
234
  private degreeKey: string | undefined;
227
235
  private active = false;
228
236
  private synth: LiveSynth | undefined;
237
+ /** Rig voices awaiting their full render: id → released since. */
238
+ private readonly windows = new Map<number, boolean>();
239
+ private readonly fullRenderer = new LiveFullRenderer();
229
240
  private readonly pending = new Map<number, Pending>();
230
241
  /** Sustain changes while recording (Tab latch or Shift), as pedal events. */
231
242
  private pendingPedal: { beat: number; state: PedalState }[] = [];
@@ -597,7 +608,7 @@ export class PlaySession {
597
608
  if (this.synth?.rate !== engine.sampleRate)
598
609
  this.synth = new LiveSynth(engine.sampleRate);
599
610
  const score = this.host.score();
600
- const pcm = this.synth.render({
611
+ const request = {
601
612
  score,
602
613
  trackId: this.trackId,
603
614
  pitch: note.pitch,
@@ -607,16 +618,48 @@ export class PlaySession {
607
618
  ...(score.time?.tempo
608
619
  ? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
609
620
  : {}),
610
- });
621
+ };
622
+ const synth = this.synth;
623
+ const pcm = synth.render(request);
611
624
  if (!pcm) return;
625
+ if (pcm.fitting) {
626
+ // A long fitted sample window is computing: silent, never off-pitch.
627
+ this.status = FITTING_STATUS;
628
+ this.stopFitWait ??= onFitReady(() => {
629
+ if (fitting()) return;
630
+ this.stopFitWait?.();
631
+ this.stopFitWait = undefined;
632
+ if (this.status === FITTING_STATUS)
633
+ this.status = "fit ready · play the key again";
634
+ });
635
+ return;
636
+ }
612
637
  const scheduled = engine.noteOn(id, pcm);
613
638
  this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
639
+ // A guitar rig sounds its first window now; the rest renders after the
640
+ // key event and replaces the voice in place while it is still sounding.
641
+ this.windows.delete(id);
642
+ if (pcm.partial) this.windows.set(id, false);
643
+ // The full pass runs on a worker, in key order, so a strummed chord or a
644
+ // fast run never stalls key handling behind an oversampled render.
645
+ if (pcm.partial)
646
+ void this.fullRenderer.full(synth, request).then((full) => {
647
+ // Not after note-off: a swap would cancel the release fade.
648
+ if (
649
+ full &&
650
+ this.windows.get(id) === false &&
651
+ engine.voicePosition?.(id) !== undefined
652
+ )
653
+ engine.noteOn(id, full);
654
+ this.windows.delete(id);
655
+ });
614
656
  }
615
657
 
616
658
  private release(ids: readonly number[], atMs: number): void {
617
659
  const engine = this.host.engine();
618
660
  for (const id of ids) {
619
661
  engine?.noteOff(id);
662
+ if (this.windows.has(id)) this.windows.set(id, true);
620
663
  const live = this.liveChords.get(id);
621
664
  if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
622
665
  const pending = this.pending.get(id);