@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -1,6 +1,6 @@
1
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
2
+ * Ctrl-k menu rows for modal percussion (0.6): the "mallets"
3
+ * group under Sound › instruments, and the MODAL_PARAMS rows under
4
4
  * Sound › Parameters on a modal track. Every row runs a `modal …` command.
5
5
  */
6
6
  import type { EnumParam, NumberParam } from "../../core/params.ts";
@@ -18,12 +18,12 @@ import {
18
18
  import type { Track } from "../../core/score.ts";
19
19
  import { num, specStep, type MenuNode } from "./menu.ts";
20
20
 
21
- /** Sound › browse sounds › Mallets and bells: one row per preset. */
21
+ /** Sound › instruments › mallets: one row per preset. */
22
22
  export function malletsMenu(): MenuNode {
23
23
  return {
24
24
  kind: "menu",
25
25
  id: "mallets",
26
- label: "Mallets and bells",
26
+ label: "mallets",
27
27
  detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
28
28
  help: "struck bars, bells and bowls on the modal resonator engine",
29
29
  build: () => [
@@ -47,14 +47,14 @@ function presetRow(name: ModalPresetName): MenuNode {
47
47
  }
48
48
 
49
49
  /**
50
- * Mallets and bells › Gamelan (f061-gamelan-winds): the Javanese and
50
+ * mallets › gamelan (f061-gamelan-winds): the Javanese and
51
51
  * Balinese bronzes. `modal gamelan` points here.
52
52
  */
53
53
  export function gamelanMenu(): MenuNode {
54
54
  return {
55
55
  kind: "menu",
56
56
  id: "gamelan",
57
- label: "Gamelan",
57
+ label: "gamelan",
58
58
  detail: `${GAMELAN_PRESETS.length} bronzes · saron, gangsa (ombak), gong …`,
59
59
  help: "Javanese and Balinese gamelan; pair two gangsa tracks with modal pair, tune with tuning slendro or pelog",
60
60
  build: () => GAMELAN_PRESETS.map(presetRow),
@@ -3,6 +3,7 @@
3
3
  * runs the same prompt command a user could type (src/commands/expression.ts),
4
4
  * so the menu, the prompt and the agent share one grammar.
5
5
  */
6
+ import { singVowelNodes } from "./sing-menu.ts";
6
7
  import {
7
8
  ARTICULATIONS,
8
9
  DEFAULT_FIXED_VELOCITY,
@@ -187,7 +188,7 @@ export function performanceNodes(context: MenuContext): MenuNode[] {
187
188
  : "pedal",
188
189
  help: "bars replaces the lane with a re-pedal on each downbeat; in play mode press Tab (latch) or hold Shift while recording",
189
190
  },
190
- // keys-electric (0.6.1): the piano's other two pedals, on modelled piano
191
+ // keys-electric (0.6.1): the piano's other two pedals, on modeled piano
191
192
  // tracks (or wherever a lane is already set).
192
193
  ...pianoPedalNodes(track),
193
194
  {
@@ -233,7 +234,7 @@ export function performanceNodes(context: MenuContext): MenuNode[] {
233
234
  },
234
235
  {
235
236
  kind: "number",
236
- label: "humanize velocity (%)",
237
+ label: "humanize vel (%)",
237
238
  value: humanize ? velocity : undefined,
238
239
  min: 0,
239
240
  max: EXPRESSION_LIMITS.maxHumanizePercent,
@@ -273,5 +274,7 @@ export function performanceNodes(context: MenuContext): MenuNode[] {
273
274
  command: "humanize reseed",
274
275
  help: "the same amounts with a different seeded performance",
275
276
  });
277
+ // 0.7 sing: Vowels on a sing track.
278
+ nodes.push(...singVowelNodes(context));
276
279
  return nodes;
277
280
  }
@@ -605,9 +605,11 @@ export function applyChordsCommand(
605
605
  return done(`chords preset ${value}`);
606
606
  case "style":
607
607
  if (!(PROGRESSION_STYLES as readonly string[]).includes(value ?? ""))
608
- return bad(`style takes ${PROGRESSION_STYLES.join("|")}`);
608
+ return bad(
609
+ `idiom takes ${PROGRESSION_STYLES.join("|")} · chords idiom jazz`,
610
+ );
609
611
  settings.style = value as ProgressionStyle;
610
- return done(`chords style ${value}`);
612
+ return done(`chords idiom ${value}`);
611
613
  default:
612
614
  return bad(
613
615
  "/chords auto|manual|off · voicing · spread · bass · perform · pattern · strokes · speed · rate · octaves · sevenths · preset · style",
@@ -32,6 +32,7 @@ import {
32
32
  type Track,
33
33
  } from "../../core/score.ts";
34
34
  import type { TuningTable } from "../../core/tuning.ts";
35
+ import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
35
36
  import { pitchName } from "../../tui/highway.ts";
36
37
 
37
38
  /** Semitone offset from the base C for each note key. */
@@ -112,10 +113,21 @@ export function defaultBaseFor(instrument: string | undefined): number {
112
113
  */
113
114
  export type PlayLayout = Readonly<{
114
115
  base: number;
115
- /** Pitch → label (voice names on one-shot samplers). */
116
+ /** Pitch → label (voice names on one-shot samplers, drums on kits). */
116
117
  labels: ReadonlyMap<number, string>;
118
+ /** A drum kit: keys name drums and the chord row stays hidden. */
119
+ drums?: boolean | undefined;
117
120
  }>;
118
121
 
122
+ /**
123
+ * A kit's keys on the GM map from C2 (36), the same keys the agent's
124
+ * show-me gestures press: A kick, W rim, S snare, E clap, T hat, H tom,
125
+ * U open hat. Other keys still sound their nearest drum, unlabeled.
126
+ */
127
+ export function drumKeyLabels(): ReadonlyMap<number, string> {
128
+ return new Map(DRUM_VOICES.map((info) => [info.pitch, info.label]));
129
+ }
130
+
119
131
  export function playLayoutFor(track: Track | undefined): PlayLayout {
120
132
  const labels = new Map<number, string>();
121
133
  if (track && isSamplerInstrument(track.instrument) && track.sampler) {
@@ -130,6 +142,8 @@ export function playLayoutFor(track: Track | undefined): PlayLayout {
130
142
  const lowest = roots.length > 0 ? Math.min(...roots) : 60;
131
143
  return { base: clampBase(Math.floor(lowest / 12) * 12), labels };
132
144
  }
145
+ if (track && isDrumInstrument(track.instrument))
146
+ return { base: 36, labels: drumKeyLabels(), drums: true };
133
147
  // A plain instrument takes its range from the track's name (`bass`, `lead`).
134
148
  const byInstrument = defaultBaseFor(track?.instrument);
135
149
  const base =
@@ -29,6 +29,7 @@ import {
29
29
  type PedalState,
30
30
  } from "../../core/expression.ts";
31
31
  import type { ClickBus } from "../audio/engine.ts";
32
+ import type { RemoteClick, RemoteNote } from "../session/shared-live.ts";
32
33
  import {
33
34
  DEFAULT_CLICK_VOLUME,
34
35
  countInClicks,
@@ -50,13 +51,16 @@ import {
50
51
  import {
51
52
  LiveFullRenderer,
52
53
  LiveSynth,
54
+ warmLive,
53
55
  MAX_LIVE_NOTE_SECONDS,
54
56
  type LiveNotePcm,
55
57
  } from "../audio/live.ts";
56
58
  import type { SampleBank } from "../audio/samples.ts";
59
+ import { loopedSection } from "../audio/arrange.ts";
57
60
  import { fitting, onFitReady } from "../audio/fit.ts";
58
61
  import {
59
62
  DEFAULT_STRUM,
63
+ degreeOf,
60
64
  perform,
61
65
  type PerformMode,
62
66
  type PerformOptions,
@@ -73,6 +77,7 @@ import {
73
77
  } from "./play-chords.ts";
74
78
  import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
75
79
  import { resolveTuning, type Tuning } from "../../core/tuning.ts";
80
+ import { singThroat } from "../../core/sing.ts";
76
81
  import {
77
82
  NOTE_KEYS,
78
83
  PlayKeyboard,
@@ -91,6 +96,10 @@ export interface LiveEngine {
91
96
  readonly sampleRate: number;
92
97
  readonly canMonitor: boolean;
93
98
  readonly leadMs: number;
99
+ /** The lead play mode pins (15 ms on the native sink, else 60 ms). */
100
+ readonly playLeadMs?: number;
101
+ /** How play mode sounds (`native sink`, `ffplay · no native sink`). */
102
+ readonly audioNote?: string;
94
103
  monitor(on: boolean): Promise<void>;
95
104
  setLeadMs(ms: number | undefined): void;
96
105
  noteOn(id: number, note: LiveNotePcm): number;
@@ -98,6 +107,14 @@ export interface LiveEngine {
98
107
  /** Frames of voice `id` mixed so far, or undefined once it has ended. */
99
108
  voicePosition?(id: number): number | undefined;
100
109
  setClick(click: ClickBus | undefined): void;
110
+ /**
111
+ * A shared engine (dawgd's, src/session/shared-live.ts) takes note events
112
+ * instead of PCM: it renders the voice itself, so one engine mixes every
113
+ * pane. Returns the monotonic ms the note is scheduled for.
114
+ */
115
+ remoteNote?(id: number, note: RemoteNote): number;
116
+ /** A shared engine's click: on/volume and the count-in, not a closure. */
117
+ remoteClick?(click: RemoteClick): void;
101
118
  }
102
119
 
103
120
  export interface PlayHost {
@@ -124,6 +141,14 @@ export interface PlayHost {
124
141
  startTransport(beat: number): Promise<void>;
125
142
  stopTransport(): Promise<void>;
126
143
  card(text: string, tone: "info" | "success" | "warning" | "error"): void;
144
+ /**
145
+ * Another pane recording on `trackId` (§12.4): its name (`pane B`), else
146
+ * undefined. A replace pass refuses while one is, since it would erase
147
+ * the other pane's notes.
148
+ */
149
+ recordingElsewhere?(trackId: string): string | undefined;
150
+ /** A played key that is not recorded: the screen glows its lane. */
151
+ ghost?(pitch: number): void;
127
152
  newNoteId(): string;
128
153
  }
129
154
 
@@ -240,6 +265,8 @@ export class PlaySession {
240
265
  private synth: LiveSynth | undefined;
241
266
  /** Rig voices awaiting their full render: id → released since. */
242
267
  private readonly windows = new Map<number, boolean>();
268
+ /** f07-sing: the sounding key on a throat track (one drone, mono). */
269
+ private throatId: number | undefined;
243
270
  private readonly fullRenderer = new LiveFullRenderer();
244
271
  private readonly pending = new Map<number, Pending>();
245
272
  /** Sustain changes while recording (Tab latch or Shift), as pedal events. */
@@ -253,6 +280,10 @@ export class PlaySession {
253
280
  /** Live pedal state as the playhead entered each loop bar (replace). */
254
281
  private readonly barPedal = new Map<number, PedalState>();
255
282
  private lastBar: number | undefined;
283
+ /** Loop recording: the pass (loop wrap) the playhead is in. */
284
+ private lastPass: number | undefined;
285
+ /** Passes committed this take (the header's `pass N`). */
286
+ private passCount = 0;
256
287
  private countIn: CountIn | undefined;
257
288
  private flushing: Promise<void> = Promise.resolve();
258
289
  /** Most recent key-to-sound schedule, ms (lead before device latency). */
@@ -289,7 +320,8 @@ export class PlaySession {
289
320
  settings.mode =
290
321
  chordCapable(track) &&
291
322
  twelveTet(this.host.score(), track) &&
292
- (track?.glide === undefined || track.glide.mode === "poly")
323
+ (track?.glide === undefined || track.glide.mode === "poly") &&
324
+ !singThroat(track)
293
325
  ? "auto"
294
326
  : "manual";
295
327
  this.chords = new ChordPad(settings, () => this.host.score().key);
@@ -331,8 +363,12 @@ export class PlaySession {
331
363
  this.active = true;
332
364
  const engine = this.host.engine();
333
365
  if (engine?.canMonitor) {
334
- engine.setLeadMs(PLAY_LEAD_MS);
366
+ engine.setLeadMs(engine.playLeadMs ?? PLAY_LEAD_MS);
367
+ // The voice's tables and loops are built now, not on the first key.
368
+ warmLive(this.host.score(), this.trackId, engine.sampleRate);
335
369
  await engine.monitor(true);
370
+ if (engine.audioNote)
371
+ this.status = `${engine.audioNote} · ${Math.round(engine.leadMs)} ms lead`;
336
372
  } else this.status = "no audio · keys still record";
337
373
  this.applyClick();
338
374
  }
@@ -344,7 +380,8 @@ export class PlaySession {
344
380
  this.countIn = undefined;
345
381
  const engine = this.host.engine();
346
382
  if (engine) {
347
- engine.setClick(this.clickOn ? this.clickBus() : undefined);
383
+ if (engine.remoteClick) this.sendClick(engine.remoteClick.bind(engine));
384
+ else engine.setClick(this.clickOn ? this.clickBus() : undefined);
348
385
  engine.setLeadMs(undefined);
349
386
  await engine.monitor(false);
350
387
  }
@@ -391,9 +428,34 @@ export class PlaySession {
391
428
  private applyClick(): void {
392
429
  const engine = this.host.engine();
393
430
  if (!engine) return;
431
+ if (engine.remoteClick) {
432
+ this.sendClick(engine.remoteClick.bind(engine));
433
+ return;
434
+ }
394
435
  engine.setClick(this.clickOn || this.countIn ? this.clickBus() : undefined);
395
436
  }
396
437
 
438
+ /** The click as a shared engine takes it: on, volume, count-in. */
439
+ private sendClick(send: (click: RemoteClick) => void): void {
440
+ const count = this.countIn;
441
+ send({
442
+ on: this.clickOn,
443
+ volume: this.clickVolume,
444
+ ...(count
445
+ ? {
446
+ countIn: {
447
+ startMs: count.startMs,
448
+ startBeat: count.startBeat,
449
+ beats: count.beats,
450
+ barBeats: count.barBeats,
451
+ clickBeats: count.clickBeats,
452
+ bpm: count.bpm,
453
+ },
454
+ }
455
+ : {}),
456
+ });
457
+ }
458
+
397
459
  /** `/click on|off|<volume>`. */
398
460
  public clickCommand(argument: string): string {
399
461
  const parsed = parseClickArgument(argument, {
@@ -421,6 +483,54 @@ export class PlaySession {
421
483
  return `grid · ${match.label}`;
422
484
  }
423
485
 
486
+ /**
487
+ * `keys record [replace|off]` and the r / R keys: arm recording, overdub
488
+ * (additive) or replace (each pass erases the bars it crossed). With a
489
+ * loop set, every pass over it is one commit, so one undo step (§4.7).
490
+ * Replace refuses while another pane records this track (§12.4).
491
+ */
492
+ public recordCommand(
493
+ mode: "overdub" | "replace" | "off",
494
+ ): Readonly<{ ok: boolean; message: string }> {
495
+ if (mode === "off") {
496
+ const was = this.armed;
497
+ this.armed = false;
498
+ this.replace = false;
499
+ if (was) void this.stopRecording();
500
+ this.status = "record off";
501
+ return { ok: true, message: "record off" };
502
+ }
503
+ if (mode === "replace") {
504
+ const other = this.host.recordingElsewhere?.(this.trackId);
505
+ if (other) {
506
+ const message = `${other} is recording ${this.trackName()} · overdub instead (r)`;
507
+ this.status = `✗ ${message}`;
508
+ return { ok: false, message };
509
+ }
510
+ }
511
+ this.replace = mode === "replace";
512
+ this.armed = true;
513
+ const message = `record ${this.replace ? "replace" : "overdub"} · ${this.trackName()} · ${this.passWord()}`;
514
+ this.status = this.replace
515
+ ? "replace · each pass overwrites its bars"
516
+ : "overdub";
517
+ return { ok: true, message };
518
+ }
519
+
520
+ private trackName(): string {
521
+ return this.trackData()?.name ?? this.trackId;
522
+ }
523
+
524
+ /** `loop 5–6 · one undo step a pass`, or `one undo step a bar`. */
525
+ private passWord(): string {
526
+ const loop = loopedSection(this.host.score());
527
+ if (!loop) return "one undo step a bar";
528
+ const last = loop.startBar + loop.bars;
529
+ const bars =
530
+ loop.bars === 1 ? `${loop.startBar + 1}` : `${loop.startBar + 1}–${last}`;
531
+ return `loop ${bars} · one undo step a pass`;
532
+ }
533
+
424
534
  /** Handle one decoded key; mode commands return to the caller. */
425
535
  public press(value: string): PlayKeyResult {
426
536
  const now = this.host.now();
@@ -483,6 +593,8 @@ export class PlaySession {
483
593
  return { type: "handled" };
484
594
  case "note": {
485
595
  this.release(action.released, now);
596
+ // A key that is not recording glows its lane for a moment.
597
+ if (!this.recording) this.host.ghost?.(action.note.pitch);
486
598
  const chord = this.chords.chordFor(action.note.pitch);
487
599
  if (chord) {
488
600
  const played = this.chords.voice(chord, action.note.pitch);
@@ -501,6 +613,12 @@ export class PlaySession {
501
613
  );
502
614
  return { type: "handled" };
503
615
  }
616
+ // f07-sing: a throat track is one drone; a new key releases the last.
617
+ if (singThroat(this.trackData())) {
618
+ if (this.throatId !== undefined && this.throatId !== action.note.id)
619
+ this.release([this.throatId], now);
620
+ this.throatId = action.note.id;
621
+ }
504
622
  this.sound(action.note.id, action.note);
505
623
  this.record(action.note);
506
624
  return { type: "handled" };
@@ -545,15 +663,12 @@ export class PlaySession {
545
663
 
546
664
  private command(command: PlayCommand): PlayKeyResult {
547
665
  if (command === "record") {
548
- this.armed = !this.armed;
549
- if (!this.armed) void this.stopRecording();
550
- this.status = this.armed ? "record armed" : "record off";
666
+ this.recordCommand(this.armed ? "off" : "overdub");
551
667
  return { type: "handled" };
552
668
  }
553
669
  if (command === "replace") {
554
- this.replace = !this.replace;
555
- this.armed = true;
556
- this.status = this.replace ? "replace · bars are overwritten" : "overdub";
670
+ const result = this.recordCommand(this.replace ? "overdub" : "replace");
671
+ if (!result.ok) this.host.card(result.message, "error");
557
672
  return { type: "handled" };
558
673
  }
559
674
  if (command === "click") {
@@ -608,6 +723,18 @@ export class PlaySession {
608
723
  const seconds = Number.isFinite(note.releaseAtMs)
609
724
  ? (note.releaseAtMs - note.atMs) / 1000
610
725
  : MAX_LIVE_NOTE_SECONDS;
726
+ if (engine.remoteNote) {
727
+ const scheduled = engine.remoteNote(id, {
728
+ trackId: this.trackId,
729
+ pitch: note.pitch,
730
+ velocity: note.velocity / 127,
731
+ seconds,
732
+ beat: this.host.beatAt(note.atMs),
733
+ atMs: note.atMs,
734
+ });
735
+ this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
736
+ return;
737
+ }
611
738
  const samples = this.host.samples?.();
612
739
  if (this.synth?.rate !== engine.sampleRate)
613
740
  this.synth = new LiveSynth(engine.sampleRate);
@@ -925,25 +1052,50 @@ export class PlaySession {
925
1052
  if (this.pending.size > 0 || this.replaceBars.size > 0)
926
1053
  void this.stopRecording();
927
1054
  this.lastBar = undefined;
1055
+ this.lastPass = undefined;
928
1056
  return;
929
1057
  }
930
1058
  const score = this.host.score();
931
1059
  const bar = transportBar(score, this.host.beatAt(now));
932
1060
  if (bar !== this.lastBar)
933
1061
  this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
1062
+ // With a loop set, a take commits once per pass (each wrap), so one
1063
+ // pass is one undo step; without one, once per bar (§4.7).
1064
+ const loop = loopedSection(score);
1065
+ const pass = loop ? Math.floor(bar / loop.bars) : undefined;
934
1066
  if (this.lastBar !== undefined && bar !== this.lastBar) {
1067
+ // Every bar the playhead left (a slow frame can skip one).
935
1068
  if (this.replace)
936
- this.replaceBars.add(
937
- this.loopBar(
938
- Math.floor(
939
- this.toScoreBeat(this.lastBar * score.beatsPerBar) /
940
- score.beatsPerBar,
1069
+ for (
1070
+ let left = this.lastBar;
1071
+ left < bar && left < this.lastBar + 256;
1072
+ left += 1
1073
+ )
1074
+ this.replaceBars.add(
1075
+ this.loopBar(
1076
+ Math.floor(
1077
+ this.toScoreBeat(left * score.beatsPerBar) / score.beatsPerBar,
1078
+ ),
941
1079
  ),
942
- ),
943
- );
944
- this.queueFlush(bar, now, false);
1080
+ );
1081
+ if (pass === undefined) this.queueFlush(bar, now, false);
1082
+ else if (this.lastPass !== undefined && pass !== this.lastPass)
1083
+ this.queueFlush(bar, now, false, true);
945
1084
  }
946
1085
  this.lastBar = bar;
1086
+ this.lastPass = pass;
1087
+ }
1088
+
1089
+ /** `↻ 5–6 · pass 3` while loop recording; undefined otherwise. */
1090
+ public passLabel(): string | undefined {
1091
+ if (!this.recording) return undefined;
1092
+ const loop = loopedSection(this.host.score());
1093
+ if (!loop) return undefined;
1094
+ const bars =
1095
+ loop.bars === 1
1096
+ ? `${loop.startBar + 1}`
1097
+ : `${loop.startBar + 1}–${loop.startBar + loop.bars}`;
1098
+ return `↻ ${bars} · pass ${this.passCount + 1}`;
947
1099
  }
948
1100
 
949
1101
  private toScoreBeat(beat: number): number {
@@ -962,15 +1114,27 @@ export class PlaySession {
962
1114
  if (this.pedalDown && this.host.playing())
963
1115
  this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
964
1116
  this.pedalDown = false;
965
- this.queueFlush(Number.POSITIVE_INFINITY, now, true);
1117
+ this.queueFlush(
1118
+ Number.POSITIVE_INFINITY,
1119
+ now,
1120
+ true,
1121
+ loopedSection(this.host.score()) !== undefined,
1122
+ );
966
1123
  await this.flushing;
967
1124
  this.recordedIds.clear();
968
1125
  this.lastBar = undefined;
1126
+ this.lastPass = undefined;
1127
+ this.passCount = 0;
969
1128
  }
970
1129
 
971
- private queueFlush(currentBar: number, now: number, all: boolean): void {
1130
+ private queueFlush(
1131
+ currentBar: number,
1132
+ now: number,
1133
+ all: boolean,
1134
+ pass = false,
1135
+ ): void {
972
1136
  this.flushing = this.flushing
973
- .then(() => this.flush(currentBar, now, all))
1137
+ .then(() => this.flush(currentBar, now, all, pass))
974
1138
  .catch((error: unknown) =>
975
1139
  this.host.card(
976
1140
  `record failed · ${error instanceof Error ? error.message : String(error)}`,
@@ -987,6 +1151,7 @@ export class PlaySession {
987
1151
  currentBar: number,
988
1152
  now: number,
989
1153
  all: boolean,
1154
+ pass = false,
990
1155
  ): Promise<void> {
991
1156
  const score = this.host.score();
992
1157
  const ready: Pending[] = [];
@@ -1003,12 +1168,39 @@ export class PlaySession {
1003
1168
  this.pendingPedal = this.pendingPedal.filter(
1004
1169
  (event) => !pedal.includes(event),
1005
1170
  );
1006
- if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
1171
+ if (pass) this.passCount += 1;
1172
+ // §12.4: a replace pass never erases while another pane records this
1173
+ // track; its notes still land, as an overdub.
1174
+ const other =
1175
+ erase.length > 0
1176
+ ? this.host.recordingElsewhere?.(this.trackId)
1177
+ : undefined;
1178
+ if (other) {
1179
+ erase.length = 0;
1180
+ this.host.card(
1181
+ `${other} is recording ${this.trackName()} · pass kept as overdub`,
1182
+ "warning",
1183
+ );
1184
+ }
1185
+ if (ready.length === 0 && erase.length === 0 && pedal.length === 0) {
1186
+ if (pass) this.recordedIds.clear();
1187
+ return;
1188
+ }
1007
1189
  const eraseStates = new Map(
1008
1190
  erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
1009
1191
  );
1010
1192
  try {
1011
- await this.commitTake(score, ready, erase, eraseStates, pedal, now);
1193
+ await this.commitTake(
1194
+ score,
1195
+ ready,
1196
+ erase,
1197
+ eraseStates,
1198
+ pedal,
1199
+ now,
1200
+ pass ? this.passCount : undefined,
1201
+ );
1202
+ // The next pass may replace this one's notes: forget them.
1203
+ if (pass) this.recordedIds.clear();
1012
1204
  } catch (error) {
1013
1205
  // Nothing was committed: keep the take so the next flush retries it.
1014
1206
  for (const pending of ready) this.pending.set(pending.id, pending);
@@ -1025,6 +1217,7 @@ export class PlaySession {
1025
1217
  eraseStates: ReadonlyMap<number, PedalState>,
1026
1218
  pedal: readonly { beat: number; state: PedalState }[],
1027
1219
  now: number,
1220
+ pass?: number,
1028
1221
  ): Promise<void> {
1029
1222
  const operations = recordOperations(score, {
1030
1223
  trackId: this.trackId,
@@ -1079,12 +1272,17 @@ export class PlaySession {
1079
1272
  trackId: this.trackId,
1080
1273
  operations,
1081
1274
  replace: this.replace,
1275
+ ...(pass !== undefined ? { pass } : {}),
1082
1276
  });
1083
1277
  for (const pending of ready) this.pending.delete(pending.id);
1084
1278
  for (const operation of operations)
1085
1279
  if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
1280
+ const head =
1281
+ pass !== undefined
1282
+ ? `pass ${pass} · +${added} note${added === 1 ? "" : "s"}`
1283
+ : `recorded ${added} note${added === 1 ? "" : "s"}`;
1086
1284
  this.host.card(
1087
- `recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1285
+ `${head}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1088
1286
  pedalNote ? "warning" : "success",
1089
1287
  );
1090
1288
  }
@@ -1143,20 +1341,25 @@ export class PlaySession {
1143
1341
  };
1144
1342
  }
1145
1343
  return {
1146
- range: this.keyboard.range,
1344
+ range: this.layout.drums ? "drums" : this.keyboard.range,
1147
1345
  velocity: this.keyboard.velocity,
1148
1346
  armed: this.armed,
1149
1347
  recording: this.recording,
1150
1348
  replace: this.replace,
1349
+ pass: this.passLabel(),
1151
1350
  click: this.clickOn,
1152
1351
  sustain: this.keyboard.sustain,
1153
1352
  countIn,
1154
1353
  beat,
1155
1354
  grid: `grid ${this.grid}`,
1156
- chords: this.chords.glance(songKey(this.host.score().key).set),
1355
+ // A kit plays drums, not harmony: no chord row and no key chip.
1356
+ chords: this.layout.drums
1357
+ ? undefined
1358
+ : this.chords.glance(songKey(this.host.score().key).set),
1157
1359
  status: this.status,
1158
1360
  keys: this.strip(now),
1159
- legend: this.chords.on ? this.chords.legend() : undefined,
1361
+ legend:
1362
+ this.chords.on && !this.layout.drums ? this.chords.legend() : undefined,
1160
1363
  };
1161
1364
  }
1162
1365
 
@@ -1178,8 +1381,20 @@ export class PlaySession {
1178
1381
  ).map((cell) => {
1179
1382
  const chord = this.keyboard.pitchFor(cell.key);
1180
1383
  const name =
1181
- chord === undefined ? undefined : this.chords.keyLabel(chord);
1182
- return name ? { ...cell, label: name } : this.noteCell(cell);
1384
+ chord === undefined || this.layout.drums
1385
+ ? undefined
1386
+ : this.chords.keyLabel(chord);
1387
+ // Chords in the key are the safe ones to reach for: the strip lights
1388
+ // them; a borrowed chord still plays but stays plain.
1389
+ return name && chord !== undefined
1390
+ ? {
1391
+ ...cell,
1392
+ label: name,
1393
+ chord: true,
1394
+ diatonic:
1395
+ degreeOf(this.chords.key, ((chord % 12) + 12) % 12) !== undefined,
1396
+ }
1397
+ : this.noteCell(cell);
1183
1398
  });
1184
1399
  }
1185
1400