@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,636 @@
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
+ import {
14
+ applyScoreOperation,
15
+ type ScoreOperation,
16
+ type TrackScore,
17
+ } from "../../core/score.ts";
18
+ import type { ClickBus } from "../audio/engine.ts";
19
+ import { DEFAULT_CLICK_VOLUME, parseClickArgument } from "../audio/click.ts";
20
+ import {
21
+ LiveSynth,
22
+ MAX_LIVE_NOTE_SECONDS,
23
+ type LiveNotePcm,
24
+ } from "../audio/live.ts";
25
+ import type { SampleBank } from "../audio/samples.ts";
26
+ import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
27
+ import {
28
+ PlayKeyboard,
29
+ playLayoutFor,
30
+ stripCells,
31
+ type PlayAction,
32
+ type PlayCommand,
33
+ type PlayLayout,
34
+ type PlayedNote,
35
+ } from "./play-mode.ts";
36
+
37
+ /** The part of AudioEngine play mode uses. */
38
+ export interface LiveEngine {
39
+ readonly sampleRate: number;
40
+ readonly canMonitor: boolean;
41
+ readonly leadMs: number;
42
+ monitor(on: boolean): Promise<void>;
43
+ setLeadMs(ms: number | undefined): void;
44
+ noteOn(id: number, note: LiveNotePcm): number;
45
+ noteOff(id: number): void;
46
+ setClick(click: ClickBus | undefined): void;
47
+ }
48
+
49
+ export interface PlayHost {
50
+ score(): TrackScore;
51
+ trackId(): string;
52
+ /** Monotonic ms (performance.now()). */
53
+ now(): number;
54
+ playing(): boolean;
55
+ /** Transport beat (unwrapped) at monotonic `ms`. */
56
+ beatAt(ms: number): number;
57
+ engine(): LiveEngine | undefined;
58
+ samples?(): SampleBank | undefined;
59
+ commit(
60
+ next: TrackScore,
61
+ kind: string,
62
+ payload: Record<string, unknown>,
63
+ ): Promise<void>;
64
+ /** Start the transport at `beat`. */
65
+ startTransport(beat: number): Promise<void>;
66
+ stopTransport(): Promise<void>;
67
+ card(text: string, tone: "info" | "success" | "warning" | "error"): void;
68
+ newNoteId(): string;
69
+ }
70
+
71
+ /** Grid steps in beats, coarse to fine. */
72
+ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
73
+ Object.freeze([
74
+ { label: "1/4", beats: 1 },
75
+ { label: "1/8", beats: 0.5 },
76
+ { label: "1/8T", beats: 1 / 3 },
77
+ { label: "1/16", beats: 0.25 },
78
+ { label: "1/16T", beats: 1 / 6 },
79
+ { label: "1/32", beats: 0.125 },
80
+ ]);
81
+ export const DEFAULT_GRID = "1/16";
82
+ /** Play mode's queue lead: short enough that a key feels immediate. */
83
+ export const PLAY_LEAD_MS = 60;
84
+ export const FLASH_MS = 110;
85
+
86
+ export function gridBeats(label: string): number | undefined {
87
+ return GRIDS.find((grid) => grid.label === label.toUpperCase())?.beats;
88
+ }
89
+
90
+ /** Snap `beat` to the nearest multiple of `step`. */
91
+ export function quantize(beat: number, step: number): number {
92
+ if (!(step > 0)) return beat;
93
+ return Math.round(beat / step) * step;
94
+ }
95
+
96
+ type Pending = {
97
+ id: number;
98
+ pitch: number;
99
+ velocity: number;
100
+ atMs: number;
101
+ /** Unwrapped transport beat of the press. */
102
+ beat: number;
103
+ releaseAtMs: number;
104
+ };
105
+
106
+ type CountIn = { startMs: number; beats: number; startBeat: number };
107
+
108
+ export type PlayKeyResult =
109
+ | { type: "handled" }
110
+ | { type: "unmapped" }
111
+ | { type: "command"; command: PlayCommand };
112
+
113
+ export class PlaySession {
114
+ public readonly keyboard: PlayKeyboard;
115
+ public layout: PlayLayout;
116
+ public armed = false;
117
+ public replace = false;
118
+ public clickOn = false;
119
+ public clickVolume = DEFAULT_CLICK_VOLUME;
120
+ public countInBars = 1;
121
+ public grid = DEFAULT_GRID;
122
+ public status: string | undefined;
123
+ private active = false;
124
+ private synth: LiveSynth | undefined;
125
+ private readonly pending = new Map<number, Pending>();
126
+ /** Played note id → recorded score note id, for this pass. */
127
+ private readonly recordedIds = new Set<string>();
128
+ /** In-loop bars the playhead finished while replacing. */
129
+ private readonly replaceBars = new Set<number>();
130
+ private lastBar: number | undefined;
131
+ private countIn: CountIn | undefined;
132
+ private flushing: Promise<void> = Promise.resolve();
133
+ /** Most recent key-to-sound schedule, ms (lead before device latency). */
134
+ public lastLatencyMs: number | undefined;
135
+ private readonly trackId: string;
136
+
137
+ /** The track this session plays and records into. */
138
+ public get track(): string {
139
+ return this.trackId;
140
+ }
141
+
142
+ public constructor(
143
+ private readonly host: PlayHost,
144
+ options: Readonly<{ clickOn?: boolean; clickVolume?: number }> = {},
145
+ ) {
146
+ this.trackId = host.trackId();
147
+ this.layout = playLayoutFor(this.trackData());
148
+ this.keyboard = new PlayKeyboard({ base: this.layout.base });
149
+ this.clickOn = options.clickOn ?? false;
150
+ this.clickVolume = options.clickVolume ?? DEFAULT_CLICK_VOLUME;
151
+ }
152
+
153
+ public get on(): boolean {
154
+ return this.active;
155
+ }
156
+
157
+ private trackData() {
158
+ return this.host
159
+ .score()
160
+ .tracks.find((track) => track.id === this.host.trackId());
161
+ }
162
+
163
+ public get gridStep(): number {
164
+ return gridBeats(this.grid) ?? 0.25;
165
+ }
166
+
167
+ /** Gate for a single press: one grid step at the current tempo. */
168
+ public gateMs(): number {
169
+ return (this.gridStep * 60_000) / this.host.score().tempoBpm;
170
+ }
171
+
172
+ public async enter(): Promise<void> {
173
+ this.active = true;
174
+ const engine = this.host.engine();
175
+ if (engine?.canMonitor) {
176
+ engine.setLeadMs(PLAY_LEAD_MS);
177
+ await engine.monitor(true);
178
+ } else this.status = "no audio · keys still record";
179
+ this.applyClick();
180
+ }
181
+
182
+ public async exit(): Promise<void> {
183
+ if (!this.active) return;
184
+ await this.stopRecording();
185
+ this.active = false;
186
+ this.countIn = undefined;
187
+ const engine = this.host.engine();
188
+ if (engine) {
189
+ engine.setClick(this.clickOn ? this.clickBus() : undefined);
190
+ engine.setLeadMs(undefined);
191
+ await engine.monitor(false);
192
+ }
193
+ }
194
+
195
+ /** The click bus the engine mixes: count-in, then the transport. */
196
+ public clickBus(): ClickBus {
197
+ const score = this.host.score();
198
+ return {
199
+ volume: this.clickVolume,
200
+ beatsPerBar: score.beatsPerBar,
201
+ subdivision: 1,
202
+ beatAt: (ms) => this.clickBeatAt(ms),
203
+ };
204
+ }
205
+
206
+ /** Beat the click follows at `ms`, or undefined while silent. */
207
+ public clickBeatAt(ms: number): number | undefined {
208
+ const count = this.countIn;
209
+ if (count) {
210
+ const beatMs = 60_000 / this.host.score().tempoBpm;
211
+ return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
212
+ }
213
+ if (!this.clickOn || !this.host.playing()) return undefined;
214
+ return this.host.beatAt(ms);
215
+ }
216
+
217
+ private applyClick(): void {
218
+ const engine = this.host.engine();
219
+ if (!engine) return;
220
+ engine.setClick(this.clickOn || this.countIn ? this.clickBus() : undefined);
221
+ }
222
+
223
+ /** `/click on|off|<volume>`. */
224
+ public clickCommand(argument: string): string {
225
+ const parsed = parseClickArgument(argument, {
226
+ on: this.clickOn,
227
+ volume: this.clickVolume,
228
+ });
229
+ if ("error" in parsed) return parsed.error;
230
+ this.clickOn = parsed.on;
231
+ this.clickVolume = parsed.volume || this.clickVolume;
232
+ this.applyClick();
233
+ return this.clickOn
234
+ ? `click on · ${Math.round(this.clickVolume * 100)}%`
235
+ : "click off";
236
+ }
237
+
238
+ public setCountIn(bars: number): string {
239
+ this.countInBars = Math.max(0, Math.min(2, Math.round(bars)));
240
+ return `count-in · ${this.countInBars} bar${this.countInBars === 1 ? "" : "s"}`;
241
+ }
242
+
243
+ public setGrid(label: string): string | undefined {
244
+ const match = GRIDS.find((grid) => grid.label === label.toUpperCase());
245
+ if (!match) return undefined;
246
+ this.grid = match.label;
247
+ return `grid · ${match.label}`;
248
+ }
249
+
250
+ /** Handle one decoded key; mode commands return to the caller. */
251
+ public press(value: string): PlayKeyResult {
252
+ const now = this.host.now();
253
+ const action = this.keyboard.press(value, now, this.gateMs());
254
+ return this.apply(action, now);
255
+ }
256
+
257
+ private apply(action: PlayAction, now: number): PlayKeyResult {
258
+ switch (action.type) {
259
+ case "unmapped":
260
+ return { type: "unmapped" };
261
+ case "command":
262
+ return this.command(action.command);
263
+ case "octave":
264
+ this.status = action.clamped
265
+ ? `octave limit · ${this.keyboard.range}`
266
+ : `octave ${this.keyboard.range}`;
267
+ return { type: "handled" };
268
+ case "velocity":
269
+ this.status = `velocity ${action.velocity}${action.clamped ? " · limit" : ""}`;
270
+ return { type: "handled" };
271
+ case "sustain":
272
+ this.release(action.released, action.atMs);
273
+ this.status = action.on ? "sustain latched" : "sustain off";
274
+ return { type: "handled" };
275
+ case "note":
276
+ this.release(action.released, now);
277
+ this.sound(action.note.id, action.note);
278
+ this.record(action.note);
279
+ return { type: "handled" };
280
+ case "extend": {
281
+ const pending = this.pending.get(action.id);
282
+ if (action.absorbed !== undefined) {
283
+ this.host.engine()?.noteOff(action.absorbed);
284
+ this.pending.delete(action.absorbed);
285
+ }
286
+ if (pending) pending.releaseAtMs = action.releaseAtMs;
287
+ const pitch = this.keyboard.pitchFor(action.key);
288
+ if (pitch !== undefined)
289
+ this.sound(action.id, {
290
+ id: action.id,
291
+ key: action.key,
292
+ pitch,
293
+ velocity: this.keyboard.velocity,
294
+ atMs: pending?.atMs ?? now,
295
+ releaseAtMs: action.releaseAtMs,
296
+ sustain: !Number.isFinite(action.releaseAtMs),
297
+ });
298
+ return { type: "handled" };
299
+ }
300
+ }
301
+ }
302
+
303
+ private command(command: PlayCommand): PlayKeyResult {
304
+ if (command === "record") {
305
+ this.armed = !this.armed;
306
+ if (!this.armed) void this.stopRecording();
307
+ this.status = this.armed ? "record armed" : "record off";
308
+ return { type: "handled" };
309
+ }
310
+ if (command === "replace") {
311
+ this.replace = !this.replace;
312
+ this.armed = true;
313
+ this.status = this.replace ? "replace · bars are overwritten" : "overdub";
314
+ return { type: "handled" };
315
+ }
316
+ if (command === "click") {
317
+ this.status = this.clickCommand("toggle");
318
+ return { type: "handled" };
319
+ }
320
+ return { type: "command", command };
321
+ }
322
+
323
+ /** Render the note through the track's own voice and start it. */
324
+ private sound(id: number, note: PlayedNote): void {
325
+ const engine = this.host.engine();
326
+ if (!engine?.canMonitor) return;
327
+ const seconds = Number.isFinite(note.releaseAtMs)
328
+ ? (note.releaseAtMs - note.atMs) / 1000
329
+ : MAX_LIVE_NOTE_SECONDS;
330
+ const samples = this.host.samples?.();
331
+ if (this.synth?.rate !== engine.sampleRate)
332
+ this.synth = new LiveSynth(engine.sampleRate);
333
+ const pcm = this.synth.render({
334
+ score: this.host.score(),
335
+ trackId: this.trackId,
336
+ pitch: note.pitch,
337
+ velocity: note.velocity / 127,
338
+ seconds,
339
+ ...(samples ? { samples } : {}),
340
+ });
341
+ if (!pcm) return;
342
+ const scheduled = engine.noteOn(id, pcm);
343
+ this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
344
+ }
345
+
346
+ private release(ids: readonly number[], atMs: number): void {
347
+ const engine = this.host.engine();
348
+ for (const id of ids) {
349
+ engine?.noteOff(id);
350
+ const pending = this.pending.get(id);
351
+ if (pending) pending.releaseAtMs = atMs;
352
+ }
353
+ }
354
+
355
+ public get recording(): boolean {
356
+ return this.armed && this.host.playing() && this.countIn === undefined;
357
+ }
358
+
359
+ private record(note: PlayedNote): void {
360
+ if (!this.recording) return;
361
+ this.pending.set(note.id, {
362
+ id: note.id,
363
+ pitch: note.pitch,
364
+ velocity: note.velocity,
365
+ atMs: note.atMs,
366
+ beat: this.host.beatAt(note.atMs),
367
+ releaseAtMs: note.releaseAtMs,
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Space in play mode: with record armed and the transport stopped, count
373
+ * in from the bar the playhead is in, then start. Returns false when the
374
+ * caller should toggle the transport itself.
375
+ */
376
+ public startWithCountIn(): boolean {
377
+ if (!this.armed || this.host.playing() || this.countIn) return false;
378
+ const score = this.host.score();
379
+ const startBeat =
380
+ Math.floor(this.host.beatAt(this.host.now()) / score.beatsPerBar) *
381
+ score.beatsPerBar;
382
+ if (this.countInBars === 0) {
383
+ void this.host.startTransport(startBeat);
384
+ return true;
385
+ }
386
+ this.countIn = {
387
+ startMs: this.host.now(),
388
+ beats: this.countInBars * score.beatsPerBar,
389
+ startBeat,
390
+ };
391
+ this.applyClick();
392
+ return true;
393
+ }
394
+
395
+ /** Advance count-in, flush finished bars. Call every frame. */
396
+ public tick(): void {
397
+ if (!this.active) return;
398
+ const now = this.host.now();
399
+ const count = this.countIn;
400
+ if (count) {
401
+ const beatMs = 60_000 / this.host.score().tempoBpm;
402
+ if (now - count.startMs >= count.beats * beatMs) {
403
+ this.countIn = undefined;
404
+ this.applyClick();
405
+ void this.host.startTransport(count.startBeat);
406
+ }
407
+ return;
408
+ }
409
+ if (!this.armed || !this.host.playing()) {
410
+ if (this.pending.size > 0 || this.replaceBars.size > 0)
411
+ void this.stopRecording();
412
+ this.lastBar = undefined;
413
+ return;
414
+ }
415
+ const score = this.host.score();
416
+ const bar = Math.floor(this.host.beatAt(now) / score.beatsPerBar);
417
+ if (this.lastBar !== undefined && bar !== this.lastBar) {
418
+ if (this.replace) this.replaceBars.add(this.loopBar(this.lastBar));
419
+ this.queueFlush(bar, now, false);
420
+ }
421
+ this.lastBar = bar;
422
+ }
423
+
424
+ private loopBar(bar: number): number {
425
+ const bars = this.host.score().bars;
426
+ return ((bar % bars) + bars) % bars;
427
+ }
428
+
429
+ /** Commit what record armed collected; called when recording ends. */
430
+ public async stopRecording(): Promise<void> {
431
+ const now = this.host.now();
432
+ this.queueFlush(Number.POSITIVE_INFINITY, now, true);
433
+ await this.flushing;
434
+ this.recordedIds.clear();
435
+ this.lastBar = undefined;
436
+ }
437
+
438
+ private queueFlush(currentBar: number, now: number, all: boolean): void {
439
+ this.flushing = this.flushing
440
+ .then(() => this.flush(currentBar, now, all))
441
+ .catch((error: unknown) =>
442
+ this.host.card(
443
+ `record failed · ${error instanceof Error ? error.message : String(error)}`,
444
+ "error",
445
+ ),
446
+ );
447
+ }
448
+
449
+ /**
450
+ * Commit pending notes whose bar is behind the playhead and whose key has
451
+ * released (all of them when `all`), plus replace-mode erasures.
452
+ */
453
+ private async flush(
454
+ currentBar: number,
455
+ now: number,
456
+ all: boolean,
457
+ ): Promise<void> {
458
+ const score = this.host.score();
459
+ const ready: Pending[] = [];
460
+ for (const pending of this.pending.values()) {
461
+ const bar = Math.floor(pending.beat / score.beatsPerBar);
462
+ const released = pending.releaseAtMs <= now;
463
+ if (all || (bar < currentBar && released)) ready.push(pending);
464
+ }
465
+ const erase = [...this.replaceBars];
466
+ this.replaceBars.clear();
467
+ if (ready.length === 0 && erase.length === 0) return;
468
+ for (const pending of ready) this.pending.delete(pending.id);
469
+ const operations = recordOperations(score, {
470
+ trackId: this.trackId,
471
+ notes: ready.map((pending) => ({
472
+ pitch: pending.pitch,
473
+ velocity: pending.velocity,
474
+ beat: pending.beat,
475
+ beats:
476
+ ((Math.min(pending.releaseAtMs, now) - pending.atMs) *
477
+ score.tempoBpm) /
478
+ 60_000,
479
+ })),
480
+ grid: this.gridStep,
481
+ eraseBars: erase,
482
+ keep: this.recordedIds,
483
+ newId: () => this.host.newNoteId(),
484
+ });
485
+ if (operations.length === 0) return;
486
+ let next = score;
487
+ for (const operation of operations)
488
+ next = applyScoreOperation(next, operation);
489
+ for (const operation of operations)
490
+ if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
491
+ const added = operations.filter((op) => op.type === "addNote").length;
492
+ const removed = operations.length - added;
493
+ await this.host.commit(next, "score.record", {
494
+ trackId: this.trackId,
495
+ operations,
496
+ replace: this.replace,
497
+ });
498
+ this.host.card(
499
+ `recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
500
+ "success",
501
+ );
502
+ }
503
+
504
+ public header(): PlayHeaderView {
505
+ const now = this.host.now();
506
+ const score = this.host.score();
507
+ const count = this.countIn;
508
+ let beat: PlayHeaderView["beat"];
509
+ let countIn: string | undefined;
510
+ const beatMs = 60_000 / score.tempoBpm;
511
+ const at = count
512
+ ? count.startBeat - count.beats + (now - count.startMs) / beatMs
513
+ : this.host.playing()
514
+ ? this.host.beatAt(now)
515
+ : undefined;
516
+ if (at !== undefined) {
517
+ const whole = Math.floor(at);
518
+ const inBar =
519
+ ((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
520
+ beat = {
521
+ index: inBar + 1,
522
+ of: score.beatsPerBar,
523
+ flash: (at - whole) * beatMs < FLASH_MS,
524
+ };
525
+ if (count) countIn = `count-in ${Math.ceil(count.startBeat - at)}`;
526
+ }
527
+ return {
528
+ range: this.keyboard.range,
529
+ velocity: this.keyboard.velocity,
530
+ armed: this.armed,
531
+ recording: this.recording,
532
+ replace: this.replace,
533
+ click: this.clickOn,
534
+ sustain: this.keyboard.sustain,
535
+ countIn,
536
+ beat,
537
+ grid: `grid ${this.grid}`,
538
+ status: this.status,
539
+ keys: this.strip(now),
540
+ };
541
+ }
542
+
543
+ public strip(now = this.host.now()): PlayStripKey[] {
544
+ return stripCells(
545
+ this.keyboard,
546
+ this.keyboard.litKeys(now),
547
+ this.layout.labels,
548
+ ).map((cell) => ({
549
+ ...cell,
550
+ // Octave digits only on C keep 18 keys inside 80 columns.
551
+ label:
552
+ this.layout.labels.size > 0 ||
553
+ (cell.label.startsWith("C") && !cell.label.startsWith("C#"))
554
+ ? cell.label
555
+ : cell.label.replace(/-?\d+$/, ""),
556
+ }));
557
+ }
558
+ }
559
+
560
+ export type RecordedNote = Readonly<{
561
+ pitch: number;
562
+ /** MIDI velocity 1..127. */
563
+ velocity: number;
564
+ /** Unwrapped transport beat of the press. */
565
+ beat: number;
566
+ /** Held length in beats. */
567
+ beats: number;
568
+ }>;
569
+
570
+ /**
571
+ * The ScoreOperations one recorded bar commits: notes quantized to `grid`
572
+ * and wrapped into the loop (overdub), plus removals of existing notes that
573
+ * start in `eraseBars` (replace), except notes this pass recorded (`keep`).
574
+ */
575
+ export function recordOperations(
576
+ score: TrackScore,
577
+ options: Readonly<{
578
+ trackId: string;
579
+ notes: readonly RecordedNote[];
580
+ grid: number;
581
+ eraseBars?: readonly number[];
582
+ keep?: ReadonlySet<string>;
583
+ newId: () => string;
584
+ }>,
585
+ ): ScoreOperation[] {
586
+ const operations: ScoreOperation[] = [];
587
+ const loopBeats = score.bars * score.beatsPerBar;
588
+ const tpb = score.ticksPerBeat;
589
+ const erase = new Set(options.eraseBars ?? []);
590
+ if (erase.size > 0)
591
+ for (const note of score.notes) {
592
+ if (note.trackId !== options.trackId) continue;
593
+ if (options.keep?.has(note.id)) continue;
594
+ const bar = Math.floor(note.startTick / tpb / score.beatsPerBar);
595
+ if (erase.has(bar))
596
+ operations.push({ type: "removeNote", noteId: note.id });
597
+ }
598
+ const removed = new Set(
599
+ operations.map((operation) =>
600
+ operation.type === "removeNote" ? operation.noteId : "",
601
+ ),
602
+ );
603
+ const taken = new Set(
604
+ score.notes
605
+ .filter(
606
+ (note) => note.trackId === options.trackId && !removed.has(note.id),
607
+ )
608
+ .map((note) => `${note.pitch}:${note.startTick}`),
609
+ );
610
+ for (const note of options.notes) {
611
+ let start = quantize(note.beat, options.grid) % loopBeats;
612
+ if (start < 0) start += loopBeats;
613
+ const beats = Math.max(options.grid, quantize(note.beats, options.grid));
614
+ const startTick = Math.round(start * tpb);
615
+ const durationTicks = Math.max(
616
+ 1,
617
+ Math.min(Math.round(beats * tpb), loopBeats * tpb - startTick),
618
+ );
619
+ const slot = `${note.pitch}:${startTick}`;
620
+ // The same pitch on the same step twice is one note (a stutter).
621
+ if (taken.has(slot)) continue;
622
+ taken.add(slot);
623
+ operations.push({
624
+ type: "addNote",
625
+ note: {
626
+ id: options.newId(),
627
+ trackId: options.trackId,
628
+ startTick,
629
+ durationTicks,
630
+ pitch: note.pitch,
631
+ velocity: Math.max(0.01, Math.min(1, note.velocity / 127)),
632
+ },
633
+ });
634
+ }
635
+ return operations;
636
+ }