@hraness/dawg 0.4.0 → 0.5.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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -20,8 +20,33 @@ import {
20
20
  type ScoreOperation,
21
21
  type TrackScore,
22
22
  } from "../../core/score.ts";
23
+ import {
24
+ EXPRESSION_LIMITS,
25
+ ExpressionValidationError,
26
+ normalizePedal,
27
+ pedalStateAt,
28
+ type PedalEvent,
29
+ type PedalState,
30
+ } from "../../core/expression.ts";
23
31
  import type { ClickBus } from "../audio/engine.ts";
24
- import { DEFAULT_CLICK_VOLUME, parseClickArgument } from "../audio/click.ts";
32
+ import {
33
+ DEFAULT_CLICK_VOLUME,
34
+ countInClicks,
35
+ meterClickGrid,
36
+ parseClickArgument,
37
+ } from "../audio/click.ts";
38
+ import {
39
+ barAt,
40
+ barStartTick,
41
+ bpmAtTick,
42
+ clickTicksOf,
43
+ hasMeterChanges,
44
+ hasTempoMap,
45
+ loopTickAt,
46
+ loopTicksOf,
47
+ transportBar,
48
+ transportBarStart,
49
+ } from "../../core/tempo.ts";
25
50
  import {
26
51
  LiveSynth,
27
52
  MAX_LIVE_NOTE_SECONDS,
@@ -43,10 +68,12 @@ import {
43
68
  type PlayedChord,
44
69
  } from "./play-chords.ts";
45
70
  import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
71
+ import { resolveTuning, type Tuning } from "../../core/tuning.ts";
46
72
  import {
47
73
  NOTE_KEYS,
48
74
  PlayKeyboard,
49
75
  REPEAT_DELAY_MS,
76
+ degreeLayout,
50
77
  playLayoutFor,
51
78
  stripCells,
52
79
  type PlayAction,
@@ -75,6 +102,11 @@ export interface PlayHost {
75
102
  playing(): boolean;
76
103
  /** Transport beat (unwrapped) at monotonic `ms`. */
77
104
  beatAt(ms: number): number;
105
+ /**
106
+ * The score beat transport `beat` plays: inside a looped section, or the
107
+ * matching bar of the form pass. Absent means the transport is the score.
108
+ */
109
+ scoreBeat?(beat: number): number;
78
110
  engine(): LiveEngine | undefined;
79
111
  samples?(): SampleBank | undefined;
80
112
  commit(
@@ -122,6 +154,12 @@ type Pending = {
122
154
  /** Unwrapped transport beat of the press. */
123
155
  beat: number;
124
156
  releaseAtMs: number;
157
+ /**
158
+ * When the key itself let go. Differs from `releaseAtMs` only while the
159
+ * sustain pedal holds the note: the recorded length is the key's, and the
160
+ * recorded pedal events do the sustaining (as a MIDI CC64 recording does).
161
+ */
162
+ keyUpMs: number;
125
163
  /** Set when the key played a chord: laid out with `perform` on flush. */
126
164
  chord?: RecordedChord;
127
165
  };
@@ -157,7 +195,16 @@ const LIVE_PLAN_BEATS = 64;
157
195
  /** Live chord voices use ids far above the keyboard's. */
158
196
  const VOICE_ID_BASE = 1_000_000_000;
159
197
 
160
- type CountIn = { startMs: number; beats: number; startBeat: number };
198
+ type CountIn = {
199
+ startMs: number;
200
+ beats: number;
201
+ startBeat: number;
202
+ /** Count-in tempo: the tempo where recording starts. */
203
+ bpm: number;
204
+ /** Beats per counted bar and per click (meter-aware). */
205
+ barBeats: number;
206
+ clickBeats: number;
207
+ };
161
208
 
162
209
  export type PlayKeyResult =
163
210
  | { type: "handled" }
@@ -174,13 +221,22 @@ export class PlaySession {
174
221
  public countInBars = 1;
175
222
  public grid = DEFAULT_GRID;
176
223
  public status: string | undefined;
224
+ /** Scale-degree layout on (`i`); follows key and tuning changes. */
225
+ public degreesOn = false;
226
+ private degreeKey: string | undefined;
177
227
  private active = false;
178
228
  private synth: LiveSynth | undefined;
179
229
  private readonly pending = new Map<number, Pending>();
230
+ /** Sustain changes while recording (Tab latch or Shift), as pedal events. */
231
+ private pendingPedal: { beat: number; state: PedalState }[] = [];
232
+ /** The pedal state last recorded this pass. */
233
+ private pedalDown = false;
180
234
  /** Played note id → recorded score note id, for this pass. */
181
235
  private readonly recordedIds = new Set<string>();
182
236
  /** In-loop bars the playhead finished while replacing. */
183
237
  private readonly replaceBars = new Set<number>();
238
+ /** Live pedal state as the playhead entered each loop bar (replace). */
239
+ private readonly barPedal = new Map<number, PedalState>();
184
240
  private lastBar: number | undefined;
185
241
  private countIn: CountIn | undefined;
186
242
  private flushing: Promise<void> = Promise.resolve();
@@ -212,8 +268,15 @@ export class PlaySession {
212
268
  this.layout = playLayoutFor(track);
213
269
  const settings = options.chords ?? defaultChordSettings();
214
270
  // Auto chords by default on chord-capable tracks, until chosen by hand.
271
+ // Diatonic triads assume 12-TET and a polyphonic part: a track in
272
+ // another tuning, or a mono/legato glide line, plays single notes.
215
273
  if (!settings.explicit)
216
- settings.mode = chordCapable(track) ? "auto" : "manual";
274
+ settings.mode =
275
+ chordCapable(track) &&
276
+ twelveTet(this.host.score(), track) &&
277
+ (track?.glide === undefined || track.glide.mode === "poly")
278
+ ? "auto"
279
+ : "manual";
217
280
  this.chords = new ChordPad(settings, () => this.host.score().key);
218
281
  this.keyboard = new PlayKeyboard({ base: this.layout.base });
219
282
  this.clickOn = options.clickOn ?? false;
@@ -236,7 +299,17 @@ export class PlaySession {
236
299
 
237
300
  /** Gate for a single press: one grid step at the current tempo. */
238
301
  public gateMs(): number {
239
- return (this.gridStep * 60_000) / this.host.score().tempoBpm;
302
+ return (this.gridStep * 60_000) / this.currentBpm();
303
+ }
304
+
305
+ /** Tempo at the playhead (the song tempo without a tempo map). */
306
+ private currentBpm(): number {
307
+ const score = this.host.score();
308
+ if (!hasTempoMap(score)) return score.tempoBpm;
309
+ return bpmAtTick(
310
+ score,
311
+ loopTickAt(score, this.host.beatAt(this.host.now())),
312
+ );
240
313
  }
241
314
 
242
315
  public async enter(): Promise<void> {
@@ -265,11 +338,27 @@ export class PlaySession {
265
338
  /** The click bus the engine mixes: count-in, then the transport. */
266
339
  public clickBus(): ClickBus {
267
340
  const score = this.host.score();
341
+ const count = this.countIn;
342
+ // Without meter changes the fixed beatsPerBar grid clicks as in 0.4.
343
+ let grid: ClickBus["grid"];
344
+ if (count && hasMeterChanges(score))
345
+ grid = (from, to) =>
346
+ countInClicks(
347
+ count.startBeat,
348
+ count.beats / count.barBeats,
349
+ count.barBeats,
350
+ count.clickBeats,
351
+ from,
352
+ to,
353
+ );
354
+ else if (!count && hasMeterChanges(score))
355
+ grid = meterClickGrid(() => this.host.score());
268
356
  return {
269
357
  volume: this.clickVolume,
270
358
  beatsPerBar: score.beatsPerBar,
271
359
  subdivision: 1,
272
360
  beatAt: (ms) => this.clickBeatAt(ms),
361
+ ...(grid ? { grid } : {}),
273
362
  };
274
363
  }
275
364
 
@@ -277,7 +366,7 @@ export class PlaySession {
277
366
  public clickBeatAt(ms: number): number | undefined {
278
367
  const count = this.countIn;
279
368
  if (count) {
280
- const beatMs = 60_000 / this.host.score().tempoBpm;
369
+ const beatMs = 60_000 / count.bpm;
281
370
  return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
282
371
  }
283
372
  if (!this.clickOn || !this.host.playing()) return undefined;
@@ -320,6 +409,7 @@ export class PlaySession {
320
409
  /** Handle one decoded key; mode commands return to the caller. */
321
410
  public press(value: string): PlayKeyResult {
322
411
  const now = this.host.now();
412
+ this.syncDegrees();
323
413
  let key = value;
324
414
  if (this.chords.on && value === "n") {
325
415
  // Route the suggestion through the note key of its root, so holding
@@ -340,16 +430,21 @@ export class PlaySession {
340
430
  }
341
431
  }
342
432
  const action = this.keyboard.press(key, now, this.gateMs());
343
- return this.apply(action, now);
433
+ const result = this.apply(action, now);
434
+ this.recordPedal(now);
435
+ return result;
344
436
  }
345
437
 
346
438
  /** The suggested next chord and the lowest note key on its root. */
347
439
  private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
348
440
  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
- );
441
+ const keys = Object.keys(NOTE_KEYS)
442
+ .map((key) => [key, this.keyboard.pitchFor(key)] as const)
443
+ .filter(
444
+ (entry): entry is readonly [string, number] => entry[1] !== undefined,
445
+ )
446
+ .sort((a, b) => a[1] - b[1]);
447
+ const found = keys.find(([, pitch]) => (pitch - chord.root) % 12 === 0);
353
448
  return { key: found?.[0] ?? "a", chord };
354
449
  }
355
450
 
@@ -402,7 +497,15 @@ export class PlaySession {
402
497
  this.stopChord(action.absorbed);
403
498
  this.pending.delete(action.absorbed);
404
499
  }
405
- if (pending) pending.releaseAtMs = action.releaseAtMs;
500
+ if (pending) {
501
+ pending.releaseAtMs = action.releaseAtMs;
502
+ pending.keyUpMs = Math.max(
503
+ pending.keyUpMs,
504
+ pending.chord || Number.isFinite(action.releaseAtMs)
505
+ ? action.releaseAtMs
506
+ : now + this.gateMs(),
507
+ );
508
+ }
406
509
  const live = this.liveChords.get(action.id);
407
510
  if (live) {
408
511
  live.releaseAtMs = action.releaseAtMs;
@@ -442,9 +545,47 @@ export class PlaySession {
442
545
  this.status = this.clickCommand("toggle");
443
546
  return { type: "handled" };
444
547
  }
548
+ if (command === "degrees") {
549
+ this.status = this.toggleDegrees();
550
+ return { type: "handled" };
551
+ }
445
552
  return { type: "command", command };
446
553
  }
447
554
 
555
+ /**
556
+ * `i`: the home row plays scale degrees (the song key's scale, or every
557
+ * step of a non-12 tuning) instead of chromatic keys. Drum kits and
558
+ * sampler slots keep their pads.
559
+ */
560
+ public toggleDegrees(on = !this.degreesOn): string {
561
+ if (on && this.layout.labels.size > 0)
562
+ return "scale degrees need a melodic track";
563
+ this.degreesOn = on;
564
+ this.degreeKey = undefined;
565
+ this.syncDegrees();
566
+ return on
567
+ ? `scale degrees · ${this.keyboard.degrees!.name} · ${this.keyboard.range}`
568
+ : `chromatic · ${this.keyboard.range}`;
569
+ }
570
+
571
+ /** Follows the song key and tuning while degree mode is on. */
572
+ private syncDegrees(): void {
573
+ if (!this.degreesOn) {
574
+ this.keyboard.degrees = undefined;
575
+ return;
576
+ }
577
+ const score = this.host.score();
578
+ const track = this.trackData();
579
+ const signature = JSON.stringify([score.key, score.tuning, track?.tuning]);
580
+ if (signature === this.degreeKey && this.keyboard.degrees) return;
581
+ this.degreeKey = signature;
582
+ const table =
583
+ score.tuning || track?.tuning
584
+ ? resolveTuning(score.tuning, track?.tuning, score.key)
585
+ : undefined;
586
+ this.keyboard.degrees = degreeLayout(score.key, table);
587
+ }
588
+
448
589
  /** Render the note through the track's own voice and start it. */
449
590
  private sound(id: number, note: PlayedNote): void {
450
591
  const engine = this.host.engine();
@@ -455,13 +596,17 @@ export class PlaySession {
455
596
  const samples = this.host.samples?.();
456
597
  if (this.synth?.rate !== engine.sampleRate)
457
598
  this.synth = new LiveSynth(engine.sampleRate);
599
+ const score = this.host.score();
458
600
  const pcm = this.synth.render({
459
- score: this.host.score(),
601
+ score,
460
602
  trackId: this.trackId,
461
603
  pitch: note.pitch,
462
604
  velocity: note.velocity / 127,
463
605
  seconds,
464
606
  ...(samples ? { samples } : {}),
607
+ ...(score.time?.tempo
608
+ ? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
609
+ : {}),
465
610
  });
466
611
  if (!pcm) return;
467
612
  const scheduled = engine.noteOn(id, pcm);
@@ -475,7 +620,10 @@ export class PlaySession {
475
620
  const live = this.liveChords.get(id);
476
621
  if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
477
622
  const pending = this.pending.get(id);
478
- if (pending) pending.releaseAtMs = atMs;
623
+ if (pending) {
624
+ pending.releaseAtMs = atMs;
625
+ pending.keyUpMs = Math.min(pending.keyUpMs, atMs);
626
+ }
479
627
  }
480
628
  }
481
629
 
@@ -492,10 +640,38 @@ export class PlaySession {
492
640
  atMs: note.atMs,
493
641
  beat: this.host.beatAt(note.atMs),
494
642
  releaseAtMs: note.releaseAtMs,
643
+ // A chord press lays its voices out over the held length (arps keep
644
+ // stepping while the pedal holds), so it keeps that length.
645
+ keyUpMs:
646
+ chord || Number.isFinite(note.releaseAtMs)
647
+ ? note.releaseAtMs
648
+ : note.atMs + this.gateMs(),
495
649
  ...(chord ? { chord } : {}),
496
650
  });
497
651
  }
498
652
 
653
+ /**
654
+ * Record a sustain change (Tab latch, or Shift held on note keys) as a
655
+ * pedal event at the playhead, like Logic's Musical Typing records its Tab
656
+ * sustain key as CC64.
657
+ */
658
+ private recordPedal(now: number): void {
659
+ // Chord presses record the held length on their voices instead.
660
+ const down =
661
+ this.keyboard.sustain &&
662
+ ![...this.pending.values()].some((pending) => pending.chord);
663
+ if (!this.recording) {
664
+ if (!down) this.pedalDown = false;
665
+ return;
666
+ }
667
+ if (down === this.pedalDown) return;
668
+ this.pedalDown = down;
669
+ this.pendingPedal.push({
670
+ beat: this.host.beatAt(now),
671
+ state: down ? "down" : "up",
672
+ });
673
+ }
674
+
499
675
  private recordedChord(
500
676
  played: PlayedChord,
501
677
  seed: number,
@@ -560,7 +736,7 @@ export class PlaySession {
560
736
  }
561
737
 
562
738
  private beatMs(): number {
563
- return 60_000 / this.host.score().tempoBpm;
739
+ return 60_000 / this.currentBpm();
564
740
  }
565
741
 
566
742
  /** Start due voices, end finished ones, drop released chords. */
@@ -639,17 +815,25 @@ export class PlaySession {
639
815
  public startWithCountIn(): boolean {
640
816
  if (!this.armed || this.host.playing() || this.countIn) return false;
641
817
  const score = this.host.score();
642
- const startBeat =
643
- Math.floor(this.host.beatAt(this.host.now()) / score.beatsPerBar) *
644
- score.beatsPerBar;
818
+ const startBeat = transportBarStart(
819
+ score,
820
+ transportBar(score, this.host.beatAt(this.host.now())),
821
+ );
645
822
  if (this.countInBars === 0) {
646
823
  void this.host.startTransport(startBeat);
647
824
  return true;
648
825
  }
826
+ // Count in the meter and tempo of the bar recording starts in.
827
+ const at = barAt(score, loopTickAt(score, startBeat));
828
+ const tpb = score.ticksPerBeat;
829
+ const barBeats = at.barTicks / tpb;
649
830
  this.countIn = {
650
831
  startMs: this.host.now(),
651
- beats: this.countInBars * score.beatsPerBar,
832
+ beats: this.countInBars * barBeats,
652
833
  startBeat,
834
+ bpm: bpmAtTick(score, at.tick),
835
+ barBeats,
836
+ clickBeats: clickTicksOf(at, tpb) / tpb,
653
837
  };
654
838
  this.applyClick();
655
839
  return true;
@@ -662,7 +846,7 @@ export class PlaySession {
662
846
  this.pumpChords(now);
663
847
  const count = this.countIn;
664
848
  if (count) {
665
- const beatMs = 60_000 / this.host.score().tempoBpm;
849
+ const beatMs = 60_000 / count.bpm;
666
850
  if (now - count.startMs >= count.beats * beatMs) {
667
851
  this.countIn = undefined;
668
852
  this.applyClick();
@@ -677,14 +861,28 @@ export class PlaySession {
677
861
  return;
678
862
  }
679
863
  const score = this.host.score();
680
- const bar = Math.floor(this.host.beatAt(now) / score.beatsPerBar);
864
+ const bar = transportBar(score, this.host.beatAt(now));
865
+ if (bar !== this.lastBar)
866
+ this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
681
867
  if (this.lastBar !== undefined && bar !== this.lastBar) {
682
- if (this.replace) this.replaceBars.add(this.loopBar(this.lastBar));
868
+ if (this.replace)
869
+ this.replaceBars.add(
870
+ this.loopBar(
871
+ Math.floor(
872
+ this.toScoreBeat(this.lastBar * score.beatsPerBar) /
873
+ score.beatsPerBar,
874
+ ),
875
+ ),
876
+ );
683
877
  this.queueFlush(bar, now, false);
684
878
  }
685
879
  this.lastBar = bar;
686
880
  }
687
881
 
882
+ private toScoreBeat(beat: number): number {
883
+ return this.host.scoreBeat ? this.host.scoreBeat(beat) : beat;
884
+ }
885
+
688
886
  private loopBar(bar: number): number {
689
887
  const bars = this.host.score().bars;
690
888
  return ((bar % bars) + bars) % bars;
@@ -693,6 +891,10 @@ export class PlaySession {
693
891
  /** Commit what record armed collected; called when recording ends. */
694
892
  public async stopRecording(): Promise<void> {
695
893
  const now = this.host.now();
894
+ // A pedal still down when recording stops lifts here.
895
+ if (this.pedalDown && this.host.playing())
896
+ this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
897
+ this.pedalDown = false;
696
898
  this.queueFlush(Number.POSITIVE_INFINITY, now, true);
697
899
  await this.flushing;
698
900
  this.recordedIds.clear();
@@ -722,24 +924,52 @@ export class PlaySession {
722
924
  const score = this.host.score();
723
925
  const ready: Pending[] = [];
724
926
  for (const pending of this.pending.values()) {
725
- const bar = Math.floor(pending.beat / score.beatsPerBar);
726
- const released = pending.releaseAtMs <= now;
927
+ const bar = transportBar(score, pending.beat);
928
+ const released = pending.keyUpMs <= now;
727
929
  if (all || (bar < currentBar && released)) ready.push(pending);
728
930
  }
729
931
  const erase = [...this.replaceBars];
730
932
  this.replaceBars.clear();
731
- if (ready.length === 0 && erase.length === 0) return;
732
- for (const pending of ready) this.pending.delete(pending.id);
933
+ const pedal = this.pendingPedal.filter(
934
+ (event) => all || transportBar(score, event.beat) < currentBar,
935
+ );
936
+ this.pendingPedal = this.pendingPedal.filter(
937
+ (event) => !pedal.includes(event),
938
+ );
939
+ if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
940
+ const eraseStates = new Map(
941
+ erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
942
+ );
943
+ try {
944
+ await this.commitTake(score, ready, erase, eraseStates, pedal, now);
945
+ } catch (error) {
946
+ // Nothing was committed: keep the take so the next flush retries it.
947
+ for (const pending of ready) this.pending.set(pending.id, pending);
948
+ for (const bar of erase) this.replaceBars.add(bar);
949
+ this.pendingPedal = [...pedal, ...this.pendingPedal];
950
+ throw error;
951
+ }
952
+ }
953
+
954
+ private async commitTake(
955
+ score: TrackScore,
956
+ ready: readonly Pending[],
957
+ erase: readonly number[],
958
+ eraseStates: ReadonlyMap<number, PedalState>,
959
+ pedal: readonly { beat: number; state: PedalState }[],
960
+ now: number,
961
+ ): Promise<void> {
733
962
  const operations = recordOperations(score, {
734
963
  trackId: this.trackId,
735
964
  notes: ready.flatMap((pending) => {
736
- const beats =
737
- ((Math.min(pending.releaseAtMs, now) - pending.atMs) *
738
- score.tempoBpm) /
739
- 60_000;
965
+ const beats = hasTempoMap(score)
966
+ ? this.host.beatAt(Math.min(pending.keyUpMs, now)) -
967
+ this.host.beatAt(pending.atMs)
968
+ : ((Math.min(pending.keyUpMs, now) - pending.atMs) * score.tempoBpm) /
969
+ 60_000;
740
970
  return pending.chord
741
971
  ? chordNotes(pending.chord, {
742
- beat: pending.beat,
972
+ beat: this.toScoreBeat(pending.beat),
743
973
  beats,
744
974
  velocity: pending.velocity,
745
975
  grid: this.gridStep,
@@ -748,7 +978,7 @@ export class PlaySession {
748
978
  {
749
979
  pitch: pending.pitch,
750
980
  velocity: pending.velocity,
751
- beat: pending.beat,
981
+ beat: this.toScoreBeat(pending.beat),
752
982
  beats,
753
983
  },
754
984
  ];
@@ -758,22 +988,37 @@ export class PlaySession {
758
988
  keep: this.recordedIds,
759
989
  newId: () => this.host.newNoteId(),
760
990
  });
991
+ // A pedal lane that cannot take more events never costs the notes.
992
+ let pedalNote = "";
993
+ try {
994
+ const pedalOperation = recordPedalOperation(score, {
995
+ trackId: this.trackId,
996
+ events: pedal,
997
+ eraseBars: erase,
998
+ eraseStates,
999
+ });
1000
+ if (pedalOperation) operations.push(pedalOperation);
1001
+ } catch (error) {
1002
+ if (!(error instanceof ExpressionValidationError)) throw error;
1003
+ pedalNote = ` · pedal not recorded (${error.message})`;
1004
+ }
761
1005
  if (operations.length === 0) return;
762
1006
  let next = score;
763
1007
  for (const operation of operations)
764
1008
  next = applyScoreOperation(next, operation);
765
- for (const operation of operations)
766
- if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
767
1009
  const added = operations.filter((op) => op.type === "addNote").length;
768
- const removed = operations.length - added;
1010
+ const removed = operations.filter((op) => op.type === "removeNote").length;
769
1011
  await this.host.commit(next, "score.record", {
770
1012
  trackId: this.trackId,
771
1013
  operations,
772
1014
  replace: this.replace,
773
1015
  });
1016
+ for (const pending of ready) this.pending.delete(pending.id);
1017
+ for (const operation of operations)
1018
+ if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
774
1019
  this.host.card(
775
- `recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
776
- "success",
1020
+ `recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1021
+ pedalNote ? "warning" : "success",
777
1022
  );
778
1023
  }
779
1024
 
@@ -783,13 +1028,13 @@ export class PlaySession {
783
1028
  const count = this.countIn;
784
1029
  let beat: PlayHeaderView["beat"];
785
1030
  let countIn: string | undefined;
786
- const beatMs = 60_000 / score.tempoBpm;
1031
+ const beatMs = 60_000 / (count?.bpm ?? score.tempoBpm);
787
1032
  const at = count
788
1033
  ? count.startBeat - count.beats + (now - count.startMs) / beatMs
789
1034
  : this.host.playing()
790
1035
  ? this.host.beatAt(now)
791
1036
  : undefined;
792
- if (at !== undefined) {
1037
+ if (at !== undefined && (count || !hasMeterChanges(score))) {
793
1038
  const whole = Math.floor(at);
794
1039
  const inBar =
795
1040
  ((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
@@ -798,7 +1043,37 @@ export class PlaySession {
798
1043
  of: score.beatsPerBar,
799
1044
  flash: (at - whole) * beatMs < FLASH_MS,
800
1045
  };
801
- if (count) countIn = `count-in ${Math.ceil(count.startBeat - at)}`;
1046
+ if (count) {
1047
+ const clicks = Math.round(count.barBeats / count.clickBeats);
1048
+ const step = Math.floor(
1049
+ (at - (count.startBeat - count.beats)) / count.clickBeats,
1050
+ );
1051
+ beat = {
1052
+ index: (((step % clicks) + clicks) % clicks) + 1,
1053
+ of: clicks,
1054
+ flash:
1055
+ ((at - (count.startBeat - count.beats)) % count.clickBeats) *
1056
+ beatMs <
1057
+ FLASH_MS,
1058
+ };
1059
+ countIn = `count-in ${Math.ceil((count.startBeat - at) / count.clickBeats)}`;
1060
+ }
1061
+ } else if (at !== undefined) {
1062
+ // Meter changes: count the clicks of the bar the playhead is in.
1063
+ const tpb = score.ticksPerBeat;
1064
+ const position = barAt(score, loopTickAt(score, at));
1065
+ const click = clickTicksOf(position, tpb);
1066
+ const inBar = position.offset / click;
1067
+ const whole = Math.floor(inBar);
1068
+ beat = {
1069
+ index: whole + 1,
1070
+ of: Math.round(position.barTicks / click),
1071
+ flash:
1072
+ ((inBar - whole) * click * 60_000) /
1073
+ tpb /
1074
+ bpmAtTick(score, position.tick + whole * click) <
1075
+ FLASH_MS,
1076
+ };
802
1077
  }
803
1078
  return {
804
1079
  range: this.keyboard.range,
@@ -828,6 +1103,7 @@ export class PlaySession {
828
1103
  }
829
1104
 
830
1105
  public strip(now = this.host.now()): PlayStripKey[] {
1106
+ this.syncDegrees();
831
1107
  return stripCells(
832
1108
  this.keyboard,
833
1109
  this.keyboard.litKeys(now),
@@ -929,14 +1205,16 @@ export function recordOperations(
929
1205
  }>,
930
1206
  ): ScoreOperation[] {
931
1207
  const operations: ScoreOperation[] = [];
932
- const loopBeats = score.bars * score.beatsPerBar;
933
1208
  const tpb = score.ticksPerBeat;
1209
+ const loopBeats = loopTicksOf(score) / tpb;
934
1210
  const erase = new Set(options.eraseBars ?? []);
935
1211
  if (erase.size > 0)
936
1212
  for (const note of score.notes) {
937
1213
  if (note.trackId !== options.trackId) continue;
938
1214
  if (options.keep?.has(note.id)) continue;
939
- const bar = Math.floor(note.startTick / tpb / score.beatsPerBar);
1215
+ const bar = hasMeterChanges(score)
1216
+ ? barAt(score, note.startTick).bar
1217
+ : Math.floor(note.startTick / tpb / score.beatsPerBar);
940
1218
  if (erase.has(bar))
941
1219
  operations.push({ type: "removeNote", noteId: note.id });
942
1220
  }
@@ -982,3 +1260,98 @@ export function recordOperations(
982
1260
  }
983
1261
  return operations;
984
1262
  }
1263
+
1264
+ /**
1265
+ * The `updateTrack` that merges recorded pedal events into the track,
1266
+ * wrapped into the loop (unquantized: pedalling is about the release), after
1267
+ * dropping existing events in `eraseBars` (replace). Undefined when nothing
1268
+ * changes.
1269
+ */
1270
+ export function recordPedalOperation(
1271
+ score: TrackScore,
1272
+ options: Readonly<{
1273
+ trackId: string;
1274
+ events: readonly { beat: number; state: PedalState }[];
1275
+ eraseBars?: readonly number[];
1276
+ /** The pedal as the take entered each erased bar (default `up`). */
1277
+ eraseStates?: ReadonlyMap<number, PedalState>;
1278
+ }>,
1279
+ ): ScoreOperation | undefined {
1280
+ const track = score.tracks.find((item) => item.id === options.trackId);
1281
+ if (!track) return undefined;
1282
+ const tpb = score.ticksPerBeat;
1283
+ // Bars follow the meter map; without meter changes this is the 0.4 grid.
1284
+ const maxTick = loopTicksOf(score);
1285
+ const original = track.pedal ?? [];
1286
+ const erase = new Set(options.eraseBars ?? []);
1287
+ const kept = original.filter(
1288
+ (event) => !erase.has(barAt(score, event.tick).bar),
1289
+ );
1290
+ if (options.events.length === 0 && erase.size === 0) return undefined;
1291
+ const byTick = new Map<number, PedalEvent>(
1292
+ kept.map((event) => [event.tick, event]),
1293
+ );
1294
+ // Replace: an erased bar starts in the state the take had there, and the
1295
+ // old pedal resumes after it, so no stale press is left without its lift.
1296
+ for (const bar of erase) {
1297
+ const start = barStartTick(score, bar);
1298
+ const end = barStartTick(score, bar + 1);
1299
+ byTick.set(start, {
1300
+ tick: start,
1301
+ state: options.eraseStates?.get(bar) ?? "up",
1302
+ });
1303
+ if (end < maxTick && !erase.has(bar + 1) && !byTick.has(end))
1304
+ byTick.set(end, { tick: end, state: pedalStateAt(original, end - 1) });
1305
+ }
1306
+ for (const event of options.events) {
1307
+ let tick = Math.round(event.beat * tpb) % maxTick;
1308
+ if (tick < 0) tick += maxTick;
1309
+ byTick.set(tick, { tick, state: event.state });
1310
+ }
1311
+ const sorted = [...byTick.values()].sort((a, b) => a.tick - b.tick);
1312
+ // A pedal held across the loop seam is down again from tick 0.
1313
+ const seam = sorted[sorted.length - 1]?.state ?? "up";
1314
+ if (options.events.length > 0 && seam !== "up" && sorted[0]?.tick !== 0)
1315
+ sorted.unshift({ tick: 0, state: seam });
1316
+ // Drop events that do not change the state (the lane has a size limit).
1317
+ const compact: PedalEvent[] = [];
1318
+ for (const event of sorted) {
1319
+ const before = compact[compact.length - 1]?.state ?? "up";
1320
+ if (event.state !== before) compact.push(event);
1321
+ }
1322
+ if (compact.length > EXPRESSION_LIMITS.maxPedalEvents)
1323
+ throw new ExpressionValidationError(
1324
+ `the pedal lane is full (${EXPRESSION_LIMITS.maxPedalEvents} events)`,
1325
+ );
1326
+ const pedal = normalizePedal(compact, maxTick);
1327
+ const same =
1328
+ (pedal?.length ?? 0) === original.length &&
1329
+ (pedal ?? []).every(
1330
+ (event, index) =>
1331
+ event.tick === original[index]!.tick &&
1332
+ event.state === original[index]!.state,
1333
+ );
1334
+ if (same) return undefined;
1335
+ return {
1336
+ type: "updateTrack",
1337
+ trackId: options.trackId,
1338
+ patch: { pedal: pedal && pedal.length > 0 ? pedal : null },
1339
+ };
1340
+ }
1341
+
1342
+ /** Whether `track` sounds in plain 12-TET (no tuning, or one equal to it). */
1343
+ function twelveTet(
1344
+ score: Readonly<{ tuning?: Tuning; key?: string | null }>,
1345
+ track: Readonly<{ tuning?: Tuning }> | undefined,
1346
+ ): boolean {
1347
+ if (!score.tuning && !track?.tuning) return true;
1348
+ const table = resolveTuning(score.tuning, track?.tuning, score.key);
1349
+ if (!table) return true;
1350
+ if (table.size !== 12 || Math.abs(table.period - 1200) > 0.01) return false;
1351
+ for (let key = 1; key < 128; key += 1) {
1352
+ const [low, high] = [table.hz[key - 1]!, table.hz[key]!];
1353
+ if (low <= 0 || high <= 0) return false;
1354
+ if (Math.abs(1200 * Math.log2(high / low) - 100) > 0.5) return false;
1355
+ }
1356
+ return true;
1357
+ }