@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,984 @@
1
+ /**
2
+ * Play mode's controller: wires the pure keyboard mapper (play-mode.ts) to
3
+ * live audition, the click bus and recording. Everything it touches goes
4
+ * through a small host interface, so tests drive it with a fake engine and
5
+ * an injected clock, and the TUI loop in main.ts only forwards keys and
6
+ * frames.
7
+ *
8
+ * Recording appends notes to the focused track through ScoreOperations
9
+ * (`addNote`, plus `removeNote` when replacing), committed once per bar as
10
+ * the playhead leaves it: one undo entry per recorded bar, synced to other
11
+ * windows and the project files like any other edit.
12
+ *
13
+ * Chord mode (play-chords.ts) turns a note key into a voiced chord: every
14
+ * voice sounds through the same live path, strums and arpeggios are paced by
15
+ * `tick()`, and a recorded chord is laid out with core/chords.ts `perform`
16
+ * so the score holds exactly what an agent's `write_chords` would write.
17
+ */
18
+ import {
19
+ applyScoreOperation,
20
+ type ScoreOperation,
21
+ type TrackScore,
22
+ } from "../../core/score.ts";
23
+ import type { ClickBus } from "../audio/engine.ts";
24
+ import { DEFAULT_CLICK_VOLUME, parseClickArgument } from "../audio/click.ts";
25
+ import {
26
+ LiveSynth,
27
+ MAX_LIVE_NOTE_SECONDS,
28
+ type LiveNotePcm,
29
+ } from "../audio/live.ts";
30
+ import type { SampleBank } from "../audio/samples.ts";
31
+ import {
32
+ DEFAULT_STRUM,
33
+ perform,
34
+ type PerformMode,
35
+ type PerformedNote,
36
+ } from "../../core/chords.ts";
37
+ import {
38
+ ChordPad,
39
+ chordCapable,
40
+ defaultChordSettings,
41
+ songKey,
42
+ type ChordSettings,
43
+ type PlayedChord,
44
+ } from "./play-chords.ts";
45
+ import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
46
+ import {
47
+ NOTE_KEYS,
48
+ PlayKeyboard,
49
+ REPEAT_DELAY_MS,
50
+ playLayoutFor,
51
+ stripCells,
52
+ type PlayAction,
53
+ type PlayCommand,
54
+ type PlayLayout,
55
+ type PlayedNote,
56
+ } from "./play-mode.ts";
57
+
58
+ /** The part of AudioEngine play mode uses. */
59
+ export interface LiveEngine {
60
+ readonly sampleRate: number;
61
+ readonly canMonitor: boolean;
62
+ readonly leadMs: number;
63
+ monitor(on: boolean): Promise<void>;
64
+ setLeadMs(ms: number | undefined): void;
65
+ noteOn(id: number, note: LiveNotePcm): number;
66
+ noteOff(id: number): void;
67
+ setClick(click: ClickBus | undefined): void;
68
+ }
69
+
70
+ export interface PlayHost {
71
+ score(): TrackScore;
72
+ trackId(): string;
73
+ /** Monotonic ms (performance.now()). */
74
+ now(): number;
75
+ playing(): boolean;
76
+ /** Transport beat (unwrapped) at monotonic `ms`. */
77
+ beatAt(ms: number): number;
78
+ engine(): LiveEngine | undefined;
79
+ samples?(): SampleBank | undefined;
80
+ commit(
81
+ next: TrackScore,
82
+ kind: string,
83
+ payload: Record<string, unknown>,
84
+ ): Promise<void>;
85
+ /** Start the transport at `beat`. */
86
+ startTransport(beat: number): Promise<void>;
87
+ stopTransport(): Promise<void>;
88
+ card(text: string, tone: "info" | "success" | "warning" | "error"): void;
89
+ newNoteId(): string;
90
+ }
91
+
92
+ /** Grid steps in beats, coarse to fine. */
93
+ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
94
+ Object.freeze([
95
+ { label: "1/4", beats: 1 },
96
+ { label: "1/8", beats: 0.5 },
97
+ { label: "1/8T", beats: 1 / 3 },
98
+ { label: "1/16", beats: 0.25 },
99
+ { label: "1/16T", beats: 1 / 6 },
100
+ { label: "1/32", beats: 0.125 },
101
+ ]);
102
+ export const DEFAULT_GRID = "1/16";
103
+ /** Play mode's queue lead: short enough that a key feels immediate. */
104
+ export const PLAY_LEAD_MS = 60;
105
+ export const FLASH_MS = 110;
106
+
107
+ export function gridBeats(label: string): number | undefined {
108
+ return GRIDS.find((grid) => grid.label === label.toUpperCase())?.beats;
109
+ }
110
+
111
+ /** Snap `beat` to the nearest multiple of `step`. */
112
+ export function quantize(beat: number, step: number): number {
113
+ if (!(step > 0)) return beat;
114
+ return Math.round(beat / step) * step;
115
+ }
116
+
117
+ type Pending = {
118
+ id: number;
119
+ pitch: number;
120
+ velocity: number;
121
+ atMs: number;
122
+ /** Unwrapped transport beat of the press. */
123
+ beat: number;
124
+ releaseAtMs: number;
125
+ /** Set when the key played a chord: laid out with `perform` on flush. */
126
+ chord?: RecordedChord;
127
+ };
128
+
129
+ /** What a recorded chord press expands to. */
130
+ export type RecordedChord = Readonly<{
131
+ pitches: readonly number[];
132
+ bass?: number | undefined;
133
+ mode: PerformMode;
134
+ /** Arp step in beats. */
135
+ rate: number;
136
+ octaves: number;
137
+ seed: number;
138
+ /** CHORD_PATTERNS name when `mode` is `pattern`. */
139
+ pattern?: string;
140
+ }>;
141
+
142
+ /** A chord sounding live: its voices start and stop from `tick()`. */
143
+ type LiveChord = {
144
+ played: PlayedChord;
145
+ velocity: number;
146
+ startMs: number;
147
+ releaseAtMs: number;
148
+ /** Voices in beats from the press, sorted by start. */
149
+ plan: PerformedNote[];
150
+ next: number;
151
+ /** Live voice id → its plan index, start and own end (Infinity: the key's). */
152
+ sounding: Map<number, { index: number; atMs: number; endMs: number }>;
153
+ };
154
+
155
+ /** Beats a held live chord's plan covers (arps keep stepping this long). */
156
+ const LIVE_PLAN_BEATS = 64;
157
+ /** Live chord voices use ids far above the keyboard's. */
158
+ const VOICE_ID_BASE = 1_000_000_000;
159
+
160
+ type CountIn = { startMs: number; beats: number; startBeat: number };
161
+
162
+ export type PlayKeyResult =
163
+ | { type: "handled" }
164
+ | { type: "unmapped" }
165
+ | { type: "command"; command: PlayCommand };
166
+
167
+ export class PlaySession {
168
+ public readonly keyboard: PlayKeyboard;
169
+ public layout: PlayLayout;
170
+ public armed = false;
171
+ public replace = false;
172
+ public clickOn = false;
173
+ public clickVolume = DEFAULT_CLICK_VOLUME;
174
+ public countInBars = 1;
175
+ public grid = DEFAULT_GRID;
176
+ public status: string | undefined;
177
+ private active = false;
178
+ private synth: LiveSynth | undefined;
179
+ private readonly pending = new Map<number, Pending>();
180
+ /** Played note id → recorded score note id, for this pass. */
181
+ private readonly recordedIds = new Set<string>();
182
+ /** In-loop bars the playhead finished while replacing. */
183
+ private readonly replaceBars = new Set<number>();
184
+ private lastBar: number | undefined;
185
+ private countIn: CountIn | undefined;
186
+ private flushing: Promise<void> = Promise.resolve();
187
+ /** Most recent key-to-sound schedule, ms (lead before device latency). */
188
+ public lastLatencyMs: number | undefined;
189
+ private readonly trackId: string;
190
+ public readonly chords: ChordPad;
191
+ private readonly liveChords = new Map<number, LiveChord>();
192
+ private nextVoiceId = VOICE_ID_BASE;
193
+ /** The `n` key's last route, so its auto-repeats hold one chord. */
194
+ private nRoute:
195
+ { key: string; chord: PlayedChord["chord"]; atMs: number } | undefined;
196
+
197
+ /** The track this session plays and records into. */
198
+ public get track(): string {
199
+ return this.trackId;
200
+ }
201
+
202
+ public constructor(
203
+ private readonly host: PlayHost,
204
+ options: Readonly<{
205
+ clickOn?: boolean;
206
+ clickVolume?: number;
207
+ chords?: ChordSettings;
208
+ }> = {},
209
+ ) {
210
+ this.trackId = host.trackId();
211
+ const track = this.trackData();
212
+ this.layout = playLayoutFor(track);
213
+ const settings = options.chords ?? defaultChordSettings();
214
+ // Auto chords by default on chord-capable tracks, until chosen by hand.
215
+ if (!settings.explicit)
216
+ settings.mode = chordCapable(track) ? "auto" : "manual";
217
+ this.chords = new ChordPad(settings, () => this.host.score().key);
218
+ this.keyboard = new PlayKeyboard({ base: this.layout.base });
219
+ this.clickOn = options.clickOn ?? false;
220
+ this.clickVolume = options.clickVolume ?? DEFAULT_CLICK_VOLUME;
221
+ }
222
+
223
+ public get on(): boolean {
224
+ return this.active;
225
+ }
226
+
227
+ private trackData() {
228
+ return this.host
229
+ .score()
230
+ .tracks.find((track) => track.id === this.host.trackId());
231
+ }
232
+
233
+ public get gridStep(): number {
234
+ return gridBeats(this.grid) ?? 0.25;
235
+ }
236
+
237
+ /** Gate for a single press: one grid step at the current tempo. */
238
+ public gateMs(): number {
239
+ return (this.gridStep * 60_000) / this.host.score().tempoBpm;
240
+ }
241
+
242
+ public async enter(): Promise<void> {
243
+ this.active = true;
244
+ const engine = this.host.engine();
245
+ if (engine?.canMonitor) {
246
+ engine.setLeadMs(PLAY_LEAD_MS);
247
+ await engine.monitor(true);
248
+ } else this.status = "no audio · keys still record";
249
+ this.applyClick();
250
+ }
251
+
252
+ public async exit(): Promise<void> {
253
+ if (!this.active) return;
254
+ await this.stopRecording();
255
+ this.active = false;
256
+ this.countIn = undefined;
257
+ const engine = this.host.engine();
258
+ if (engine) {
259
+ engine.setClick(this.clickOn ? this.clickBus() : undefined);
260
+ engine.setLeadMs(undefined);
261
+ await engine.monitor(false);
262
+ }
263
+ }
264
+
265
+ /** The click bus the engine mixes: count-in, then the transport. */
266
+ public clickBus(): ClickBus {
267
+ const score = this.host.score();
268
+ return {
269
+ volume: this.clickVolume,
270
+ beatsPerBar: score.beatsPerBar,
271
+ subdivision: 1,
272
+ beatAt: (ms) => this.clickBeatAt(ms),
273
+ };
274
+ }
275
+
276
+ /** Beat the click follows at `ms`, or undefined while silent. */
277
+ public clickBeatAt(ms: number): number | undefined {
278
+ const count = this.countIn;
279
+ if (count) {
280
+ const beatMs = 60_000 / this.host.score().tempoBpm;
281
+ return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
282
+ }
283
+ if (!this.clickOn || !this.host.playing()) return undefined;
284
+ return this.host.beatAt(ms);
285
+ }
286
+
287
+ private applyClick(): void {
288
+ const engine = this.host.engine();
289
+ if (!engine) return;
290
+ engine.setClick(this.clickOn || this.countIn ? this.clickBus() : undefined);
291
+ }
292
+
293
+ /** `/click on|off|<volume>`. */
294
+ public clickCommand(argument: string): string {
295
+ const parsed = parseClickArgument(argument, {
296
+ on: this.clickOn,
297
+ volume: this.clickVolume,
298
+ });
299
+ if ("error" in parsed) return parsed.error;
300
+ this.clickOn = parsed.on;
301
+ this.clickVolume = parsed.volume || this.clickVolume;
302
+ this.applyClick();
303
+ return this.clickOn
304
+ ? `click on · ${Math.round(this.clickVolume * 100)}%`
305
+ : "click off";
306
+ }
307
+
308
+ public setCountIn(bars: number): string {
309
+ this.countInBars = Math.max(0, Math.min(2, Math.round(bars)));
310
+ return `count-in · ${this.countInBars} bar${this.countInBars === 1 ? "" : "s"}`;
311
+ }
312
+
313
+ public setGrid(label: string): string | undefined {
314
+ const match = GRIDS.find((grid) => grid.label === label.toUpperCase());
315
+ if (!match) return undefined;
316
+ this.grid = match.label;
317
+ return `grid · ${match.label}`;
318
+ }
319
+
320
+ /** Handle one decoded key; mode commands return to the caller. */
321
+ public press(value: string): PlayKeyResult {
322
+ const now = this.host.now();
323
+ let key = value;
324
+ if (this.chords.on && value === "n") {
325
+ // Route the suggestion through the note key of its root, so holding
326
+ // `n` sustains it like any held key.
327
+ const route =
328
+ this.nRoute && now - this.nRoute.atMs <= REPEAT_DELAY_MS
329
+ ? this.nRoute
330
+ : { ...this.nextRoute(), atMs: now };
331
+ route.atMs = now;
332
+ this.nRoute = route;
333
+ this.chords.force(route.chord);
334
+ key = route.key;
335
+ } else {
336
+ const pad = this.chords.press(value);
337
+ if (pad.type === "status") {
338
+ this.status = pad.status;
339
+ return { type: "handled" };
340
+ }
341
+ }
342
+ const action = this.keyboard.press(key, now, this.gateMs());
343
+ return this.apply(action, now);
344
+ }
345
+
346
+ /** The suggested next chord and the lowest note key on its root. */
347
+ private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
348
+ const chord = this.chords.next();
349
+ const keys = Object.entries(NOTE_KEYS).sort((a, b) => a[1] - b[1]);
350
+ const found = keys.find(
351
+ ([, offset]) => (this.keyboard.base + offset - chord.root) % 12 === 0,
352
+ );
353
+ return { key: found?.[0] ?? "a", chord };
354
+ }
355
+
356
+ private apply(action: PlayAction, now: number): PlayKeyResult {
357
+ switch (action.type) {
358
+ case "unmapped":
359
+ return { type: "unmapped" };
360
+ case "command":
361
+ return this.command(action.command);
362
+ case "octave":
363
+ this.status = action.clamped
364
+ ? `octave limit · ${this.keyboard.range}`
365
+ : `octave ${this.keyboard.range}`;
366
+ return { type: "handled" };
367
+ case "velocity":
368
+ this.status = `velocity ${action.velocity}${action.clamped ? " · limit" : ""}`;
369
+ return { type: "handled" };
370
+ case "sustain":
371
+ this.release(action.released, action.atMs);
372
+ this.status = action.on ? "sustain latched" : "sustain off";
373
+ return { type: "handled" };
374
+ case "note": {
375
+ this.release(action.released, now);
376
+ const chord = this.chords.chordFor(action.note.pitch);
377
+ if (chord) {
378
+ const played = this.chords.voice(chord, action.note.pitch);
379
+ this.startChord(action.note, played);
380
+ this.record(action.note, this.recordedChord(played, action.note.id));
381
+ this.status = played.name;
382
+ return { type: "handled" };
383
+ }
384
+ // Bass modes that sound bass under single notes (unison, single, solo).
385
+ const single = this.chords.single(action.note.pitch);
386
+ if (single) {
387
+ this.startChord(action.note, single, "block");
388
+ this.record(
389
+ action.note,
390
+ this.recordedChord(single, action.note.id, "block"),
391
+ );
392
+ return { type: "handled" };
393
+ }
394
+ this.sound(action.note.id, action.note);
395
+ this.record(action.note);
396
+ return { type: "handled" };
397
+ }
398
+ case "extend": {
399
+ const pending = this.pending.get(action.id);
400
+ if (action.absorbed !== undefined) {
401
+ this.host.engine()?.noteOff(action.absorbed);
402
+ this.stopChord(action.absorbed);
403
+ this.pending.delete(action.absorbed);
404
+ }
405
+ if (pending) pending.releaseAtMs = action.releaseAtMs;
406
+ const live = this.liveChords.get(action.id);
407
+ if (live) {
408
+ live.releaseAtMs = action.releaseAtMs;
409
+ this.resoundChord(live);
410
+ return { type: "handled" };
411
+ }
412
+ const pitch = this.keyboard.pitchFor(action.key);
413
+ if (pitch !== undefined)
414
+ this.sound(action.id, {
415
+ id: action.id,
416
+ key: action.key,
417
+ pitch,
418
+ velocity: this.keyboard.velocity,
419
+ atMs: pending?.atMs ?? now,
420
+ releaseAtMs: action.releaseAtMs,
421
+ sustain: !Number.isFinite(action.releaseAtMs),
422
+ });
423
+ return { type: "handled" };
424
+ }
425
+ }
426
+ }
427
+
428
+ private command(command: PlayCommand): PlayKeyResult {
429
+ if (command === "record") {
430
+ this.armed = !this.armed;
431
+ if (!this.armed) void this.stopRecording();
432
+ this.status = this.armed ? "record armed" : "record off";
433
+ return { type: "handled" };
434
+ }
435
+ if (command === "replace") {
436
+ this.replace = !this.replace;
437
+ this.armed = true;
438
+ this.status = this.replace ? "replace · bars are overwritten" : "overdub";
439
+ return { type: "handled" };
440
+ }
441
+ if (command === "click") {
442
+ this.status = this.clickCommand("toggle");
443
+ return { type: "handled" };
444
+ }
445
+ return { type: "command", command };
446
+ }
447
+
448
+ /** Render the note through the track's own voice and start it. */
449
+ private sound(id: number, note: PlayedNote): void {
450
+ const engine = this.host.engine();
451
+ if (!engine?.canMonitor) return;
452
+ const seconds = Number.isFinite(note.releaseAtMs)
453
+ ? (note.releaseAtMs - note.atMs) / 1000
454
+ : MAX_LIVE_NOTE_SECONDS;
455
+ const samples = this.host.samples?.();
456
+ if (this.synth?.rate !== engine.sampleRate)
457
+ this.synth = new LiveSynth(engine.sampleRate);
458
+ const pcm = this.synth.render({
459
+ score: this.host.score(),
460
+ trackId: this.trackId,
461
+ pitch: note.pitch,
462
+ velocity: note.velocity / 127,
463
+ seconds,
464
+ ...(samples ? { samples } : {}),
465
+ });
466
+ if (!pcm) return;
467
+ const scheduled = engine.noteOn(id, pcm);
468
+ this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
469
+ }
470
+
471
+ private release(ids: readonly number[], atMs: number): void {
472
+ const engine = this.host.engine();
473
+ for (const id of ids) {
474
+ engine?.noteOff(id);
475
+ const live = this.liveChords.get(id);
476
+ if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
477
+ const pending = this.pending.get(id);
478
+ if (pending) pending.releaseAtMs = atMs;
479
+ }
480
+ }
481
+
482
+ public get recording(): boolean {
483
+ return this.armed && this.host.playing() && this.countIn === undefined;
484
+ }
485
+
486
+ private record(note: PlayedNote, chord?: RecordedChord): void {
487
+ if (!this.recording) return;
488
+ this.pending.set(note.id, {
489
+ id: note.id,
490
+ pitch: note.pitch,
491
+ velocity: note.velocity,
492
+ atMs: note.atMs,
493
+ beat: this.host.beatAt(note.atMs),
494
+ releaseAtMs: note.releaseAtMs,
495
+ ...(chord ? { chord } : {}),
496
+ });
497
+ }
498
+
499
+ private recordedChord(
500
+ played: PlayedChord,
501
+ seed: number,
502
+ mode: PerformMode = this.chords.settings.perform,
503
+ ): RecordedChord {
504
+ const settings = this.chords.settings;
505
+ return {
506
+ pitches: played.pitches,
507
+ bass: played.bass,
508
+ mode,
509
+ rate: this.chords.rateBeats(this.gridStep),
510
+ octaves: settings.octaves,
511
+ seed,
512
+ ...(mode === "pattern" ? { pattern: settings.pattern } : {}),
513
+ };
514
+ }
515
+
516
+ // ── live chords ──────────────────────────────────────────────────────
517
+
518
+ private startChord(
519
+ note: PlayedNote,
520
+ played: PlayedChord,
521
+ mode: PerformMode = this.chords.settings.perform,
522
+ ): void {
523
+ const settings = this.chords.settings;
524
+ // Velocity 1: pattern hits carry their own accents, scaled by the press.
525
+ const plan = perform(played.pitches, 0, LIVE_PLAN_BEATS, {
526
+ mode,
527
+ rate: this.chords.rateBeats(this.gridStep),
528
+ octaves: settings.octaves,
529
+ strum: mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
530
+ seed: note.id,
531
+ pattern: settings.pattern,
532
+ velocity: 1,
533
+ });
534
+ if (played.bass !== undefined)
535
+ plan.push({
536
+ pitch: played.bass,
537
+ start: 0,
538
+ length: LIVE_PLAN_BEATS,
539
+ velocity: 1,
540
+ });
541
+ plan.sort((a, b) => a.start - b.start);
542
+ this.liveChords.set(note.id, {
543
+ played,
544
+ velocity: note.velocity,
545
+ startMs: note.atMs,
546
+ releaseAtMs: note.releaseAtMs,
547
+ plan,
548
+ next: 0,
549
+ sounding: new Map(),
550
+ });
551
+ this.pumpChords(this.host.now());
552
+ }
553
+
554
+ private stopChord(id: number): void {
555
+ const live = this.liveChords.get(id);
556
+ if (!live) return;
557
+ const engine = this.host.engine();
558
+ for (const voice of live.sounding.keys()) engine?.noteOff(voice);
559
+ this.liveChords.delete(id);
560
+ }
561
+
562
+ private beatMs(): number {
563
+ return 60_000 / this.host.score().tempoBpm;
564
+ }
565
+
566
+ /** Start due voices, end finished ones, drop released chords. */
567
+ private pumpChords(now: number): void {
568
+ if (this.liveChords.size === 0) return;
569
+ const engine = this.host.engine();
570
+ const beatMs = this.beatMs();
571
+ for (const [id, live] of this.liveChords) {
572
+ for (const [voice, state] of live.sounding)
573
+ if (state.endMs <= now || live.releaseAtMs <= now) {
574
+ engine?.noteOff(voice);
575
+ live.sounding.delete(voice);
576
+ }
577
+ while (live.next < live.plan.length) {
578
+ const planned = live.plan[live.next]!;
579
+ const atMs = live.startMs + planned.start * beatMs;
580
+ if (atMs > now || atMs >= live.releaseAtMs) break;
581
+ live.next += 1;
582
+ const endMs = live.startMs + (planned.start + planned.length) * beatMs;
583
+ // Notes that ring to the end of the plan follow the key instead.
584
+ const own = planned.start + planned.length < LIVE_PLAN_BEATS - 1e-6;
585
+ const voice = this.nextVoiceId++;
586
+ live.sounding.set(voice, {
587
+ index: live.next - 1,
588
+ atMs,
589
+ endMs: own ? endMs : Infinity,
590
+ });
591
+ this.soundVoice(
592
+ voice,
593
+ live,
594
+ planned,
595
+ atMs,
596
+ own ? endMs : live.releaseAtMs,
597
+ );
598
+ }
599
+ const exhausted =
600
+ live.next >= live.plan.length ||
601
+ live.startMs + live.plan[live.next]!.start * beatMs >= live.releaseAtMs;
602
+ if (live.releaseAtMs <= now || (exhausted && live.sounding.size === 0))
603
+ this.stopChord(id);
604
+ }
605
+ }
606
+
607
+ /** A held chord's release moved: re-render the voices that follow it. */
608
+ private resoundChord(live: LiveChord): void {
609
+ for (const [voice, state] of live.sounding)
610
+ if (!Number.isFinite(state.endMs)) {
611
+ const planned = live.plan[state.index]!;
612
+ this.soundVoice(voice, live, planned, state.atMs, live.releaseAtMs);
613
+ }
614
+ }
615
+
616
+ private soundVoice(
617
+ voice: number,
618
+ live: LiveChord,
619
+ planned: Readonly<{ pitch: number; velocity: number }>,
620
+ atMs: number,
621
+ endMs: number,
622
+ ): void {
623
+ this.sound(voice, {
624
+ id: voice,
625
+ key: "",
626
+ pitch: planned.pitch,
627
+ velocity: scaleVelocity(live.velocity, planned.velocity),
628
+ atMs,
629
+ releaseAtMs: endMs,
630
+ sustain: !Number.isFinite(endMs),
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Space in play mode: with record armed and the transport stopped, count
636
+ * in from the bar the playhead is in, then start. Returns false when the
637
+ * caller should toggle the transport itself.
638
+ */
639
+ public startWithCountIn(): boolean {
640
+ if (!this.armed || this.host.playing() || this.countIn) return false;
641
+ const score = this.host.score();
642
+ const startBeat =
643
+ Math.floor(this.host.beatAt(this.host.now()) / score.beatsPerBar) *
644
+ score.beatsPerBar;
645
+ if (this.countInBars === 0) {
646
+ void this.host.startTransport(startBeat);
647
+ return true;
648
+ }
649
+ this.countIn = {
650
+ startMs: this.host.now(),
651
+ beats: this.countInBars * score.beatsPerBar,
652
+ startBeat,
653
+ };
654
+ this.applyClick();
655
+ return true;
656
+ }
657
+
658
+ /** Advance count-in, flush finished bars. Call every frame. */
659
+ public tick(): void {
660
+ if (!this.active) return;
661
+ const now = this.host.now();
662
+ this.pumpChords(now);
663
+ const count = this.countIn;
664
+ if (count) {
665
+ const beatMs = 60_000 / this.host.score().tempoBpm;
666
+ if (now - count.startMs >= count.beats * beatMs) {
667
+ this.countIn = undefined;
668
+ this.applyClick();
669
+ void this.host.startTransport(count.startBeat);
670
+ }
671
+ return;
672
+ }
673
+ if (!this.armed || !this.host.playing()) {
674
+ if (this.pending.size > 0 || this.replaceBars.size > 0)
675
+ void this.stopRecording();
676
+ this.lastBar = undefined;
677
+ return;
678
+ }
679
+ const score = this.host.score();
680
+ const bar = Math.floor(this.host.beatAt(now) / score.beatsPerBar);
681
+ if (this.lastBar !== undefined && bar !== this.lastBar) {
682
+ if (this.replace) this.replaceBars.add(this.loopBar(this.lastBar));
683
+ this.queueFlush(bar, now, false);
684
+ }
685
+ this.lastBar = bar;
686
+ }
687
+
688
+ private loopBar(bar: number): number {
689
+ const bars = this.host.score().bars;
690
+ return ((bar % bars) + bars) % bars;
691
+ }
692
+
693
+ /** Commit what record armed collected; called when recording ends. */
694
+ public async stopRecording(): Promise<void> {
695
+ const now = this.host.now();
696
+ this.queueFlush(Number.POSITIVE_INFINITY, now, true);
697
+ await this.flushing;
698
+ this.recordedIds.clear();
699
+ this.lastBar = undefined;
700
+ }
701
+
702
+ private queueFlush(currentBar: number, now: number, all: boolean): void {
703
+ this.flushing = this.flushing
704
+ .then(() => this.flush(currentBar, now, all))
705
+ .catch((error: unknown) =>
706
+ this.host.card(
707
+ `record failed · ${error instanceof Error ? error.message : String(error)}`,
708
+ "error",
709
+ ),
710
+ );
711
+ }
712
+
713
+ /**
714
+ * Commit pending notes whose bar is behind the playhead and whose key has
715
+ * released (all of them when `all`), plus replace-mode erasures.
716
+ */
717
+ private async flush(
718
+ currentBar: number,
719
+ now: number,
720
+ all: boolean,
721
+ ): Promise<void> {
722
+ const score = this.host.score();
723
+ const ready: Pending[] = [];
724
+ for (const pending of this.pending.values()) {
725
+ const bar = Math.floor(pending.beat / score.beatsPerBar);
726
+ const released = pending.releaseAtMs <= now;
727
+ if (all || (bar < currentBar && released)) ready.push(pending);
728
+ }
729
+ const erase = [...this.replaceBars];
730
+ this.replaceBars.clear();
731
+ if (ready.length === 0 && erase.length === 0) return;
732
+ for (const pending of ready) this.pending.delete(pending.id);
733
+ const operations = recordOperations(score, {
734
+ trackId: this.trackId,
735
+ notes: ready.flatMap((pending) => {
736
+ const beats =
737
+ ((Math.min(pending.releaseAtMs, now) - pending.atMs) *
738
+ score.tempoBpm) /
739
+ 60_000;
740
+ return pending.chord
741
+ ? chordNotes(pending.chord, {
742
+ beat: pending.beat,
743
+ beats,
744
+ velocity: pending.velocity,
745
+ grid: this.gridStep,
746
+ })
747
+ : [
748
+ {
749
+ pitch: pending.pitch,
750
+ velocity: pending.velocity,
751
+ beat: pending.beat,
752
+ beats,
753
+ },
754
+ ];
755
+ }),
756
+ grid: this.gridStep,
757
+ eraseBars: erase,
758
+ keep: this.recordedIds,
759
+ newId: () => this.host.newNoteId(),
760
+ });
761
+ if (operations.length === 0) return;
762
+ let next = score;
763
+ for (const operation of operations)
764
+ next = applyScoreOperation(next, operation);
765
+ for (const operation of operations)
766
+ if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
767
+ const added = operations.filter((op) => op.type === "addNote").length;
768
+ const removed = operations.length - added;
769
+ await this.host.commit(next, "score.record", {
770
+ trackId: this.trackId,
771
+ operations,
772
+ replace: this.replace,
773
+ });
774
+ this.host.card(
775
+ `recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
776
+ "success",
777
+ );
778
+ }
779
+
780
+ public header(): PlayHeaderView {
781
+ const now = this.host.now();
782
+ const score = this.host.score();
783
+ const count = this.countIn;
784
+ let beat: PlayHeaderView["beat"];
785
+ let countIn: string | undefined;
786
+ const beatMs = 60_000 / score.tempoBpm;
787
+ const at = count
788
+ ? count.startBeat - count.beats + (now - count.startMs) / beatMs
789
+ : this.host.playing()
790
+ ? this.host.beatAt(now)
791
+ : undefined;
792
+ if (at !== undefined) {
793
+ const whole = Math.floor(at);
794
+ const inBar =
795
+ ((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
796
+ beat = {
797
+ index: inBar + 1,
798
+ of: score.beatsPerBar,
799
+ flash: (at - whole) * beatMs < FLASH_MS,
800
+ };
801
+ if (count) countIn = `count-in ${Math.ceil(count.startBeat - at)}`;
802
+ }
803
+ return {
804
+ range: this.keyboard.range,
805
+ velocity: this.keyboard.velocity,
806
+ armed: this.armed,
807
+ recording: this.recording,
808
+ replace: this.replace,
809
+ click: this.clickOn,
810
+ sustain: this.keyboard.sustain,
811
+ countIn,
812
+ beat,
813
+ grid: `grid ${this.grid}`,
814
+ chords: this.chords.glance(songKey(this.host.score().key).set),
815
+ status: this.status,
816
+ keys: this.strip(now),
817
+ legend: this.chords.on ? this.chords.legend() : undefined,
818
+ };
819
+ }
820
+
821
+ /** Secondary state for the `?` panel (kept out of the header). */
822
+ public details(): string[] {
823
+ const keySet = songKey(this.host.score().key).set;
824
+ return [
825
+ `velocity ${this.keyboard.velocity} · grid ${this.grid} · click ${this.clickOn ? "on" : "off"} · count-in ${this.countInBars} bar${this.countInBars === 1 ? "" : "s"}`,
826
+ ...(this.chords.on ? [`chords ${this.chords.headerText(keySet)}`] : []),
827
+ ];
828
+ }
829
+
830
+ public strip(now = this.host.now()): PlayStripKey[] {
831
+ return stripCells(
832
+ this.keyboard,
833
+ this.keyboard.litKeys(now),
834
+ this.layout.labels,
835
+ ).map((cell) => {
836
+ const chord = this.keyboard.pitchFor(cell.key);
837
+ const name =
838
+ chord === undefined ? undefined : this.chords.keyLabel(chord);
839
+ return name ? { ...cell, label: name } : this.noteCell(cell);
840
+ });
841
+ }
842
+
843
+ private noteCell(cell: PlayStripKey): PlayStripKey {
844
+ return {
845
+ ...cell,
846
+ // Octave digits only on C keep 18 keys inside 80 columns.
847
+ label:
848
+ this.layout.labels.size > 0 ||
849
+ (cell.label.startsWith("C") && !cell.label.startsWith("C#"))
850
+ ? cell.label
851
+ : cell.label.replace(/-?\d+$/, ""),
852
+ };
853
+ }
854
+ }
855
+
856
+ export type RecordedNote = Readonly<{
857
+ pitch: number;
858
+ /** MIDI velocity 1..127. */
859
+ velocity: number;
860
+ /** Unwrapped transport beat of the press. */
861
+ beat: number;
862
+ /** Held length in beats. */
863
+ beats: number;
864
+ /** Already on the grid (a laid-out chord voice): keep beat and length. */
865
+ exact?: boolean;
866
+ }>;
867
+
868
+ /** A press velocity (1..127) times a pattern accent (0..1), at least 1. */
869
+ function scaleVelocity(velocity: number, accent: number): number {
870
+ return accent >= 1 ? velocity : Math.max(1, Math.round(velocity * accent));
871
+ }
872
+
873
+ /**
874
+ * A recorded chord press as notes: the press snaps to `grid`, its held
875
+ * length rounds to whole grid steps (at least one), and `perform` lays the
876
+ * voices out inside it; the bass holds the whole length.
877
+ */
878
+ export function chordNotes(
879
+ chord: RecordedChord,
880
+ press: Readonly<{
881
+ beat: number;
882
+ beats: number;
883
+ velocity: number;
884
+ grid: number;
885
+ }>,
886
+ ): RecordedNote[] {
887
+ const start = quantize(press.beat, press.grid);
888
+ const length = Math.max(press.grid, quantize(press.beats, press.grid));
889
+ const notes = perform(chord.pitches, start, length, {
890
+ mode: chord.mode,
891
+ rate: chord.rate,
892
+ octaves: chord.octaves,
893
+ strum: chord.mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
894
+ seed: chord.seed,
895
+ ...(chord.pattern !== undefined ? { pattern: chord.pattern } : {}),
896
+ velocity: 1,
897
+ }).map((note) => ({
898
+ pitch: note.pitch,
899
+ velocity: scaleVelocity(press.velocity, note.velocity),
900
+ beat: note.start,
901
+ beats: note.length,
902
+ exact: true,
903
+ }));
904
+ if (chord.bass !== undefined)
905
+ notes.push({
906
+ pitch: chord.bass,
907
+ velocity: press.velocity,
908
+ beat: start,
909
+ beats: length,
910
+ exact: true,
911
+ });
912
+ return notes;
913
+ }
914
+
915
+ /**
916
+ * The ScoreOperations one recorded bar commits: notes quantized to `grid`
917
+ * and wrapped into the loop (overdub), plus removals of existing notes that
918
+ * start in `eraseBars` (replace), except notes this pass recorded (`keep`).
919
+ */
920
+ export function recordOperations(
921
+ score: TrackScore,
922
+ options: Readonly<{
923
+ trackId: string;
924
+ notes: readonly RecordedNote[];
925
+ grid: number;
926
+ eraseBars?: readonly number[];
927
+ keep?: ReadonlySet<string>;
928
+ newId: () => string;
929
+ }>,
930
+ ): ScoreOperation[] {
931
+ const operations: ScoreOperation[] = [];
932
+ const loopBeats = score.bars * score.beatsPerBar;
933
+ const tpb = score.ticksPerBeat;
934
+ const erase = new Set(options.eraseBars ?? []);
935
+ if (erase.size > 0)
936
+ for (const note of score.notes) {
937
+ if (note.trackId !== options.trackId) continue;
938
+ if (options.keep?.has(note.id)) continue;
939
+ const bar = Math.floor(note.startTick / tpb / score.beatsPerBar);
940
+ if (erase.has(bar))
941
+ operations.push({ type: "removeNote", noteId: note.id });
942
+ }
943
+ const removed = new Set(
944
+ operations.map((operation) =>
945
+ operation.type === "removeNote" ? operation.noteId : "",
946
+ ),
947
+ );
948
+ const taken = new Set(
949
+ score.notes
950
+ .filter(
951
+ (note) => note.trackId === options.trackId && !removed.has(note.id),
952
+ )
953
+ .map((note) => `${note.pitch}:${note.startTick}`),
954
+ );
955
+ for (const note of options.notes) {
956
+ let start =
957
+ (note.exact ? note.beat : quantize(note.beat, options.grid)) % loopBeats;
958
+ if (start < 0) start += loopBeats;
959
+ const beats = note.exact
960
+ ? note.beats
961
+ : Math.max(options.grid, quantize(note.beats, options.grid));
962
+ const startTick = Math.round(start * tpb);
963
+ const durationTicks = Math.max(
964
+ 1,
965
+ Math.min(Math.round(beats * tpb), loopBeats * tpb - startTick),
966
+ );
967
+ const slot = `${note.pitch}:${startTick}`;
968
+ // The same pitch on the same step twice is one note (a stutter).
969
+ if (taken.has(slot)) continue;
970
+ taken.add(slot);
971
+ operations.push({
972
+ type: "addNote",
973
+ note: {
974
+ id: options.newId(),
975
+ trackId: options.trackId,
976
+ startTick,
977
+ durationTicks,
978
+ pitch: note.pitch,
979
+ velocity: Math.max(0.01, Math.min(1, note.velocity / 127)),
980
+ },
981
+ });
982
+ }
983
+ return operations;
984
+ }