@hraness/dawg 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -20,14 +20,41 @@ 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";
25
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";
50
+ import {
51
+ LiveFullRenderer,
26
52
  LiveSynth,
27
53
  MAX_LIVE_NOTE_SECONDS,
28
54
  type LiveNotePcm,
29
55
  } from "../audio/live.ts";
30
56
  import type { SampleBank } from "../audio/samples.ts";
57
+ import { fitting, onFitReady } from "../audio/fit.ts";
31
58
  import {
32
59
  DEFAULT_STRUM,
33
60
  perform,
@@ -43,10 +70,12 @@ import {
43
70
  type PlayedChord,
44
71
  } from "./play-chords.ts";
45
72
  import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
73
+ import { resolveTuning, type Tuning } from "../../core/tuning.ts";
46
74
  import {
47
75
  NOTE_KEYS,
48
76
  PlayKeyboard,
49
77
  REPEAT_DELAY_MS,
78
+ degreeLayout,
50
79
  playLayoutFor,
51
80
  stripCells,
52
81
  type PlayAction,
@@ -64,6 +93,8 @@ export interface LiveEngine {
64
93
  setLeadMs(ms: number | undefined): void;
65
94
  noteOn(id: number, note: LiveNotePcm): number;
66
95
  noteOff(id: number): void;
96
+ /** Frames of voice `id` mixed so far, or undefined once it has ended. */
97
+ voicePosition?(id: number): number | undefined;
67
98
  setClick(click: ClickBus | undefined): void;
68
99
  }
69
100
 
@@ -75,6 +106,11 @@ export interface PlayHost {
75
106
  playing(): boolean;
76
107
  /** Transport beat (unwrapped) at monotonic `ms`. */
77
108
  beatAt(ms: number): number;
109
+ /**
110
+ * The score beat transport `beat` plays: inside a looped section, or the
111
+ * matching bar of the form pass. Absent means the transport is the score.
112
+ */
113
+ scoreBeat?(beat: number): number;
78
114
  engine(): LiveEngine | undefined;
79
115
  samples?(): SampleBank | undefined;
80
116
  commit(
@@ -102,6 +138,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
102
138
  export const DEFAULT_GRID = "1/16";
103
139
  /** Play mode's queue lead: short enough that a key feels immediate. */
104
140
  export const PLAY_LEAD_MS = 60;
141
+ /** Status while a long fitted sample window computes (fit.ts). */
142
+ const FITTING_STATUS = "fitting · the sample plays once ready";
105
143
  export const FLASH_MS = 110;
106
144
 
107
145
  export function gridBeats(label: string): number | undefined {
@@ -122,6 +160,12 @@ type Pending = {
122
160
  /** Unwrapped transport beat of the press. */
123
161
  beat: number;
124
162
  releaseAtMs: number;
163
+ /**
164
+ * When the key itself let go. Differs from `releaseAtMs` only while the
165
+ * sustain pedal holds the note: the recorded length is the key's, and the
166
+ * recorded pedal events do the sustaining (as a MIDI CC64 recording does).
167
+ */
168
+ keyUpMs: number;
125
169
  /** Set when the key played a chord: laid out with `perform` on flush. */
126
170
  chord?: RecordedChord;
127
171
  };
@@ -157,7 +201,16 @@ const LIVE_PLAN_BEATS = 64;
157
201
  /** Live chord voices use ids far above the keyboard's. */
158
202
  const VOICE_ID_BASE = 1_000_000_000;
159
203
 
160
- type CountIn = { startMs: number; beats: number; startBeat: number };
204
+ type CountIn = {
205
+ startMs: number;
206
+ beats: number;
207
+ startBeat: number;
208
+ /** Count-in tempo: the tempo where recording starts. */
209
+ bpm: number;
210
+ /** Beats per counted bar and per click (meter-aware). */
211
+ barBeats: number;
212
+ clickBeats: number;
213
+ };
161
214
 
162
215
  export type PlayKeyResult =
163
216
  | { type: "handled" }
@@ -174,13 +227,27 @@ export class PlaySession {
174
227
  public countInBars = 1;
175
228
  public grid = DEFAULT_GRID;
176
229
  public status: string | undefined;
230
+ /** Unsubscribes the pending "fit ready" listener (fit.ts). */
231
+ private stopFitWait: (() => void) | undefined;
232
+ /** Scale-degree layout on (`i`); follows key and tuning changes. */
233
+ public degreesOn = false;
234
+ private degreeKey: string | undefined;
177
235
  private active = false;
178
236
  private synth: LiveSynth | undefined;
237
+ /** Rig voices awaiting their full render: id → released since. */
238
+ private readonly windows = new Map<number, boolean>();
239
+ private readonly fullRenderer = new LiveFullRenderer();
179
240
  private readonly pending = new Map<number, Pending>();
241
+ /** Sustain changes while recording (Tab latch or Shift), as pedal events. */
242
+ private pendingPedal: { beat: number; state: PedalState }[] = [];
243
+ /** The pedal state last recorded this pass. */
244
+ private pedalDown = false;
180
245
  /** Played note id → recorded score note id, for this pass. */
181
246
  private readonly recordedIds = new Set<string>();
182
247
  /** In-loop bars the playhead finished while replacing. */
183
248
  private readonly replaceBars = new Set<number>();
249
+ /** Live pedal state as the playhead entered each loop bar (replace). */
250
+ private readonly barPedal = new Map<number, PedalState>();
184
251
  private lastBar: number | undefined;
185
252
  private countIn: CountIn | undefined;
186
253
  private flushing: Promise<void> = Promise.resolve();
@@ -212,8 +279,15 @@ export class PlaySession {
212
279
  this.layout = playLayoutFor(track);
213
280
  const settings = options.chords ?? defaultChordSettings();
214
281
  // Auto chords by default on chord-capable tracks, until chosen by hand.
282
+ // Diatonic triads assume 12-TET and a polyphonic part: a track in
283
+ // another tuning, or a mono/legato glide line, plays single notes.
215
284
  if (!settings.explicit)
216
- settings.mode = chordCapable(track) ? "auto" : "manual";
285
+ settings.mode =
286
+ chordCapable(track) &&
287
+ twelveTet(this.host.score(), track) &&
288
+ (track?.glide === undefined || track.glide.mode === "poly")
289
+ ? "auto"
290
+ : "manual";
217
291
  this.chords = new ChordPad(settings, () => this.host.score().key);
218
292
  this.keyboard = new PlayKeyboard({ base: this.layout.base });
219
293
  this.clickOn = options.clickOn ?? false;
@@ -236,7 +310,17 @@ export class PlaySession {
236
310
 
237
311
  /** Gate for a single press: one grid step at the current tempo. */
238
312
  public gateMs(): number {
239
- return (this.gridStep * 60_000) / this.host.score().tempoBpm;
313
+ return (this.gridStep * 60_000) / this.currentBpm();
314
+ }
315
+
316
+ /** Tempo at the playhead (the song tempo without a tempo map). */
317
+ private currentBpm(): number {
318
+ const score = this.host.score();
319
+ if (!hasTempoMap(score)) return score.tempoBpm;
320
+ return bpmAtTick(
321
+ score,
322
+ loopTickAt(score, this.host.beatAt(this.host.now())),
323
+ );
240
324
  }
241
325
 
242
326
  public async enter(): Promise<void> {
@@ -265,11 +349,27 @@ export class PlaySession {
265
349
  /** The click bus the engine mixes: count-in, then the transport. */
266
350
  public clickBus(): ClickBus {
267
351
  const score = this.host.score();
352
+ const count = this.countIn;
353
+ // Without meter changes the fixed beatsPerBar grid clicks as in 0.4.
354
+ let grid: ClickBus["grid"];
355
+ if (count && hasMeterChanges(score))
356
+ grid = (from, to) =>
357
+ countInClicks(
358
+ count.startBeat,
359
+ count.beats / count.barBeats,
360
+ count.barBeats,
361
+ count.clickBeats,
362
+ from,
363
+ to,
364
+ );
365
+ else if (!count && hasMeterChanges(score))
366
+ grid = meterClickGrid(() => this.host.score());
268
367
  return {
269
368
  volume: this.clickVolume,
270
369
  beatsPerBar: score.beatsPerBar,
271
370
  subdivision: 1,
272
371
  beatAt: (ms) => this.clickBeatAt(ms),
372
+ ...(grid ? { grid } : {}),
273
373
  };
274
374
  }
275
375
 
@@ -277,7 +377,7 @@ export class PlaySession {
277
377
  public clickBeatAt(ms: number): number | undefined {
278
378
  const count = this.countIn;
279
379
  if (count) {
280
- const beatMs = 60_000 / this.host.score().tempoBpm;
380
+ const beatMs = 60_000 / count.bpm;
281
381
  return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
282
382
  }
283
383
  if (!this.clickOn || !this.host.playing()) return undefined;
@@ -320,6 +420,7 @@ export class PlaySession {
320
420
  /** Handle one decoded key; mode commands return to the caller. */
321
421
  public press(value: string): PlayKeyResult {
322
422
  const now = this.host.now();
423
+ this.syncDegrees();
323
424
  let key = value;
324
425
  if (this.chords.on && value === "n") {
325
426
  // Route the suggestion through the note key of its root, so holding
@@ -340,16 +441,21 @@ export class PlaySession {
340
441
  }
341
442
  }
342
443
  const action = this.keyboard.press(key, now, this.gateMs());
343
- return this.apply(action, now);
444
+ const result = this.apply(action, now);
445
+ this.recordPedal(now);
446
+ return result;
344
447
  }
345
448
 
346
449
  /** The suggested next chord and the lowest note key on its root. */
347
450
  private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
348
451
  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
- );
452
+ const keys = Object.keys(NOTE_KEYS)
453
+ .map((key) => [key, this.keyboard.pitchFor(key)] as const)
454
+ .filter(
455
+ (entry): entry is readonly [string, number] => entry[1] !== undefined,
456
+ )
457
+ .sort((a, b) => a[1] - b[1]);
458
+ const found = keys.find(([, pitch]) => (pitch - chord.root) % 12 === 0);
353
459
  return { key: found?.[0] ?? "a", chord };
354
460
  }
355
461
 
@@ -402,7 +508,15 @@ export class PlaySession {
402
508
  this.stopChord(action.absorbed);
403
509
  this.pending.delete(action.absorbed);
404
510
  }
405
- if (pending) pending.releaseAtMs = action.releaseAtMs;
511
+ if (pending) {
512
+ pending.releaseAtMs = action.releaseAtMs;
513
+ pending.keyUpMs = Math.max(
514
+ pending.keyUpMs,
515
+ pending.chord || Number.isFinite(action.releaseAtMs)
516
+ ? action.releaseAtMs
517
+ : now + this.gateMs(),
518
+ );
519
+ }
406
520
  const live = this.liveChords.get(action.id);
407
521
  if (live) {
408
522
  live.releaseAtMs = action.releaseAtMs;
@@ -442,9 +556,47 @@ export class PlaySession {
442
556
  this.status = this.clickCommand("toggle");
443
557
  return { type: "handled" };
444
558
  }
559
+ if (command === "degrees") {
560
+ this.status = this.toggleDegrees();
561
+ return { type: "handled" };
562
+ }
445
563
  return { type: "command", command };
446
564
  }
447
565
 
566
+ /**
567
+ * `i`: the home row plays scale degrees (the song key's scale, or every
568
+ * step of a non-12 tuning) instead of chromatic keys. Drum kits and
569
+ * sampler slots keep their pads.
570
+ */
571
+ public toggleDegrees(on = !this.degreesOn): string {
572
+ if (on && this.layout.labels.size > 0)
573
+ return "scale degrees need a melodic track";
574
+ this.degreesOn = on;
575
+ this.degreeKey = undefined;
576
+ this.syncDegrees();
577
+ return on
578
+ ? `scale degrees · ${this.keyboard.degrees!.name} · ${this.keyboard.range}`
579
+ : `chromatic · ${this.keyboard.range}`;
580
+ }
581
+
582
+ /** Follows the song key and tuning while degree mode is on. */
583
+ private syncDegrees(): void {
584
+ if (!this.degreesOn) {
585
+ this.keyboard.degrees = undefined;
586
+ return;
587
+ }
588
+ const score = this.host.score();
589
+ const track = this.trackData();
590
+ const signature = JSON.stringify([score.key, score.tuning, track?.tuning]);
591
+ if (signature === this.degreeKey && this.keyboard.degrees) return;
592
+ this.degreeKey = signature;
593
+ const table =
594
+ score.tuning || track?.tuning
595
+ ? resolveTuning(score.tuning, track?.tuning, score.key)
596
+ : undefined;
597
+ this.keyboard.degrees = degreeLayout(score.key, table);
598
+ }
599
+
448
600
  /** Render the note through the track's own voice and start it. */
449
601
  private sound(id: number, note: PlayedNote): void {
450
602
  const engine = this.host.engine();
@@ -455,27 +607,66 @@ export class PlaySession {
455
607
  const samples = this.host.samples?.();
456
608
  if (this.synth?.rate !== engine.sampleRate)
457
609
  this.synth = new LiveSynth(engine.sampleRate);
458
- const pcm = this.synth.render({
459
- score: this.host.score(),
610
+ const score = this.host.score();
611
+ const request = {
612
+ score,
460
613
  trackId: this.trackId,
461
614
  pitch: note.pitch,
462
615
  velocity: note.velocity / 127,
463
616
  seconds,
464
617
  ...(samples ? { samples } : {}),
465
- });
618
+ ...(score.time?.tempo
619
+ ? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
620
+ : {}),
621
+ };
622
+ const synth = this.synth;
623
+ const pcm = synth.render(request);
466
624
  if (!pcm) return;
625
+ if (pcm.fitting) {
626
+ // A long fitted sample window is computing: silent, never off-pitch.
627
+ this.status = FITTING_STATUS;
628
+ this.stopFitWait ??= onFitReady(() => {
629
+ if (fitting()) return;
630
+ this.stopFitWait?.();
631
+ this.stopFitWait = undefined;
632
+ if (this.status === FITTING_STATUS)
633
+ this.status = "fit ready · play the key again";
634
+ });
635
+ return;
636
+ }
467
637
  const scheduled = engine.noteOn(id, pcm);
468
638
  this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
639
+ // A guitar rig sounds its first window now; the rest renders after the
640
+ // key event and replaces the voice in place while it is still sounding.
641
+ this.windows.delete(id);
642
+ if (pcm.partial) this.windows.set(id, false);
643
+ // The full pass runs on a worker, in key order, so a strummed chord or a
644
+ // fast run never stalls key handling behind an oversampled render.
645
+ if (pcm.partial)
646
+ void this.fullRenderer.full(synth, request).then((full) => {
647
+ // Not after note-off: a swap would cancel the release fade.
648
+ if (
649
+ full &&
650
+ this.windows.get(id) === false &&
651
+ engine.voicePosition?.(id) !== undefined
652
+ )
653
+ engine.noteOn(id, full);
654
+ this.windows.delete(id);
655
+ });
469
656
  }
470
657
 
471
658
  private release(ids: readonly number[], atMs: number): void {
472
659
  const engine = this.host.engine();
473
660
  for (const id of ids) {
474
661
  engine?.noteOff(id);
662
+ if (this.windows.has(id)) this.windows.set(id, true);
475
663
  const live = this.liveChords.get(id);
476
664
  if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
477
665
  const pending = this.pending.get(id);
478
- if (pending) pending.releaseAtMs = atMs;
666
+ if (pending) {
667
+ pending.releaseAtMs = atMs;
668
+ pending.keyUpMs = Math.min(pending.keyUpMs, atMs);
669
+ }
479
670
  }
480
671
  }
481
672
 
@@ -492,10 +683,38 @@ export class PlaySession {
492
683
  atMs: note.atMs,
493
684
  beat: this.host.beatAt(note.atMs),
494
685
  releaseAtMs: note.releaseAtMs,
686
+ // A chord press lays its voices out over the held length (arps keep
687
+ // stepping while the pedal holds), so it keeps that length.
688
+ keyUpMs:
689
+ chord || Number.isFinite(note.releaseAtMs)
690
+ ? note.releaseAtMs
691
+ : note.atMs + this.gateMs(),
495
692
  ...(chord ? { chord } : {}),
496
693
  });
497
694
  }
498
695
 
696
+ /**
697
+ * Record a sustain change (Tab latch, or Shift held on note keys) as a
698
+ * pedal event at the playhead, like Logic's Musical Typing records its Tab
699
+ * sustain key as CC64.
700
+ */
701
+ private recordPedal(now: number): void {
702
+ // Chord presses record the held length on their voices instead.
703
+ const down =
704
+ this.keyboard.sustain &&
705
+ ![...this.pending.values()].some((pending) => pending.chord);
706
+ if (!this.recording) {
707
+ if (!down) this.pedalDown = false;
708
+ return;
709
+ }
710
+ if (down === this.pedalDown) return;
711
+ this.pedalDown = down;
712
+ this.pendingPedal.push({
713
+ beat: this.host.beatAt(now),
714
+ state: down ? "down" : "up",
715
+ });
716
+ }
717
+
499
718
  private recordedChord(
500
719
  played: PlayedChord,
501
720
  seed: number,
@@ -560,7 +779,7 @@ export class PlaySession {
560
779
  }
561
780
 
562
781
  private beatMs(): number {
563
- return 60_000 / this.host.score().tempoBpm;
782
+ return 60_000 / this.currentBpm();
564
783
  }
565
784
 
566
785
  /** Start due voices, end finished ones, drop released chords. */
@@ -639,17 +858,25 @@ export class PlaySession {
639
858
  public startWithCountIn(): boolean {
640
859
  if (!this.armed || this.host.playing() || this.countIn) return false;
641
860
  const score = this.host.score();
642
- const startBeat =
643
- Math.floor(this.host.beatAt(this.host.now()) / score.beatsPerBar) *
644
- score.beatsPerBar;
861
+ const startBeat = transportBarStart(
862
+ score,
863
+ transportBar(score, this.host.beatAt(this.host.now())),
864
+ );
645
865
  if (this.countInBars === 0) {
646
866
  void this.host.startTransport(startBeat);
647
867
  return true;
648
868
  }
869
+ // Count in the meter and tempo of the bar recording starts in.
870
+ const at = barAt(score, loopTickAt(score, startBeat));
871
+ const tpb = score.ticksPerBeat;
872
+ const barBeats = at.barTicks / tpb;
649
873
  this.countIn = {
650
874
  startMs: this.host.now(),
651
- beats: this.countInBars * score.beatsPerBar,
875
+ beats: this.countInBars * barBeats,
652
876
  startBeat,
877
+ bpm: bpmAtTick(score, at.tick),
878
+ barBeats,
879
+ clickBeats: clickTicksOf(at, tpb) / tpb,
653
880
  };
654
881
  this.applyClick();
655
882
  return true;
@@ -662,7 +889,7 @@ export class PlaySession {
662
889
  this.pumpChords(now);
663
890
  const count = this.countIn;
664
891
  if (count) {
665
- const beatMs = 60_000 / this.host.score().tempoBpm;
892
+ const beatMs = 60_000 / count.bpm;
666
893
  if (now - count.startMs >= count.beats * beatMs) {
667
894
  this.countIn = undefined;
668
895
  this.applyClick();
@@ -677,14 +904,28 @@ export class PlaySession {
677
904
  return;
678
905
  }
679
906
  const score = this.host.score();
680
- const bar = Math.floor(this.host.beatAt(now) / score.beatsPerBar);
907
+ const bar = transportBar(score, this.host.beatAt(now));
908
+ if (bar !== this.lastBar)
909
+ this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
681
910
  if (this.lastBar !== undefined && bar !== this.lastBar) {
682
- if (this.replace) this.replaceBars.add(this.loopBar(this.lastBar));
911
+ if (this.replace)
912
+ this.replaceBars.add(
913
+ this.loopBar(
914
+ Math.floor(
915
+ this.toScoreBeat(this.lastBar * score.beatsPerBar) /
916
+ score.beatsPerBar,
917
+ ),
918
+ ),
919
+ );
683
920
  this.queueFlush(bar, now, false);
684
921
  }
685
922
  this.lastBar = bar;
686
923
  }
687
924
 
925
+ private toScoreBeat(beat: number): number {
926
+ return this.host.scoreBeat ? this.host.scoreBeat(beat) : beat;
927
+ }
928
+
688
929
  private loopBar(bar: number): number {
689
930
  const bars = this.host.score().bars;
690
931
  return ((bar % bars) + bars) % bars;
@@ -693,6 +934,10 @@ export class PlaySession {
693
934
  /** Commit what record armed collected; called when recording ends. */
694
935
  public async stopRecording(): Promise<void> {
695
936
  const now = this.host.now();
937
+ // A pedal still down when recording stops lifts here.
938
+ if (this.pedalDown && this.host.playing())
939
+ this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
940
+ this.pedalDown = false;
696
941
  this.queueFlush(Number.POSITIVE_INFINITY, now, true);
697
942
  await this.flushing;
698
943
  this.recordedIds.clear();
@@ -722,24 +967,52 @@ export class PlaySession {
722
967
  const score = this.host.score();
723
968
  const ready: Pending[] = [];
724
969
  for (const pending of this.pending.values()) {
725
- const bar = Math.floor(pending.beat / score.beatsPerBar);
726
- const released = pending.releaseAtMs <= now;
970
+ const bar = transportBar(score, pending.beat);
971
+ const released = pending.keyUpMs <= now;
727
972
  if (all || (bar < currentBar && released)) ready.push(pending);
728
973
  }
729
974
  const erase = [...this.replaceBars];
730
975
  this.replaceBars.clear();
731
- if (ready.length === 0 && erase.length === 0) return;
732
- for (const pending of ready) this.pending.delete(pending.id);
976
+ const pedal = this.pendingPedal.filter(
977
+ (event) => all || transportBar(score, event.beat) < currentBar,
978
+ );
979
+ this.pendingPedal = this.pendingPedal.filter(
980
+ (event) => !pedal.includes(event),
981
+ );
982
+ if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
983
+ const eraseStates = new Map(
984
+ erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
985
+ );
986
+ try {
987
+ await this.commitTake(score, ready, erase, eraseStates, pedal, now);
988
+ } catch (error) {
989
+ // Nothing was committed: keep the take so the next flush retries it.
990
+ for (const pending of ready) this.pending.set(pending.id, pending);
991
+ for (const bar of erase) this.replaceBars.add(bar);
992
+ this.pendingPedal = [...pedal, ...this.pendingPedal];
993
+ throw error;
994
+ }
995
+ }
996
+
997
+ private async commitTake(
998
+ score: TrackScore,
999
+ ready: readonly Pending[],
1000
+ erase: readonly number[],
1001
+ eraseStates: ReadonlyMap<number, PedalState>,
1002
+ pedal: readonly { beat: number; state: PedalState }[],
1003
+ now: number,
1004
+ ): Promise<void> {
733
1005
  const operations = recordOperations(score, {
734
1006
  trackId: this.trackId,
735
1007
  notes: ready.flatMap((pending) => {
736
- const beats =
737
- ((Math.min(pending.releaseAtMs, now) - pending.atMs) *
738
- score.tempoBpm) /
739
- 60_000;
1008
+ const beats = hasTempoMap(score)
1009
+ ? this.host.beatAt(Math.min(pending.keyUpMs, now)) -
1010
+ this.host.beatAt(pending.atMs)
1011
+ : ((Math.min(pending.keyUpMs, now) - pending.atMs) * score.tempoBpm) /
1012
+ 60_000;
740
1013
  return pending.chord
741
1014
  ? chordNotes(pending.chord, {
742
- beat: pending.beat,
1015
+ beat: this.toScoreBeat(pending.beat),
743
1016
  beats,
744
1017
  velocity: pending.velocity,
745
1018
  grid: this.gridStep,
@@ -748,7 +1021,7 @@ export class PlaySession {
748
1021
  {
749
1022
  pitch: pending.pitch,
750
1023
  velocity: pending.velocity,
751
- beat: pending.beat,
1024
+ beat: this.toScoreBeat(pending.beat),
752
1025
  beats,
753
1026
  },
754
1027
  ];
@@ -758,22 +1031,37 @@ export class PlaySession {
758
1031
  keep: this.recordedIds,
759
1032
  newId: () => this.host.newNoteId(),
760
1033
  });
1034
+ // A pedal lane that cannot take more events never costs the notes.
1035
+ let pedalNote = "";
1036
+ try {
1037
+ const pedalOperation = recordPedalOperation(score, {
1038
+ trackId: this.trackId,
1039
+ events: pedal,
1040
+ eraseBars: erase,
1041
+ eraseStates,
1042
+ });
1043
+ if (pedalOperation) operations.push(pedalOperation);
1044
+ } catch (error) {
1045
+ if (!(error instanceof ExpressionValidationError)) throw error;
1046
+ pedalNote = ` · pedal not recorded (${error.message})`;
1047
+ }
761
1048
  if (operations.length === 0) return;
762
1049
  let next = score;
763
1050
  for (const operation of operations)
764
1051
  next = applyScoreOperation(next, operation);
765
- for (const operation of operations)
766
- if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
767
1052
  const added = operations.filter((op) => op.type === "addNote").length;
768
- const removed = operations.length - added;
1053
+ const removed = operations.filter((op) => op.type === "removeNote").length;
769
1054
  await this.host.commit(next, "score.record", {
770
1055
  trackId: this.trackId,
771
1056
  operations,
772
1057
  replace: this.replace,
773
1058
  });
1059
+ for (const pending of ready) this.pending.delete(pending.id);
1060
+ for (const operation of operations)
1061
+ if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
774
1062
  this.host.card(
775
- `recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
776
- "success",
1063
+ `recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
1064
+ pedalNote ? "warning" : "success",
777
1065
  );
778
1066
  }
779
1067
 
@@ -783,13 +1071,13 @@ export class PlaySession {
783
1071
  const count = this.countIn;
784
1072
  let beat: PlayHeaderView["beat"];
785
1073
  let countIn: string | undefined;
786
- const beatMs = 60_000 / score.tempoBpm;
1074
+ const beatMs = 60_000 / (count?.bpm ?? score.tempoBpm);
787
1075
  const at = count
788
1076
  ? count.startBeat - count.beats + (now - count.startMs) / beatMs
789
1077
  : this.host.playing()
790
1078
  ? this.host.beatAt(now)
791
1079
  : undefined;
792
- if (at !== undefined) {
1080
+ if (at !== undefined && (count || !hasMeterChanges(score))) {
793
1081
  const whole = Math.floor(at);
794
1082
  const inBar =
795
1083
  ((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
@@ -798,7 +1086,37 @@ export class PlaySession {
798
1086
  of: score.beatsPerBar,
799
1087
  flash: (at - whole) * beatMs < FLASH_MS,
800
1088
  };
801
- if (count) countIn = `count-in ${Math.ceil(count.startBeat - at)}`;
1089
+ if (count) {
1090
+ const clicks = Math.round(count.barBeats / count.clickBeats);
1091
+ const step = Math.floor(
1092
+ (at - (count.startBeat - count.beats)) / count.clickBeats,
1093
+ );
1094
+ beat = {
1095
+ index: (((step % clicks) + clicks) % clicks) + 1,
1096
+ of: clicks,
1097
+ flash:
1098
+ ((at - (count.startBeat - count.beats)) % count.clickBeats) *
1099
+ beatMs <
1100
+ FLASH_MS,
1101
+ };
1102
+ countIn = `count-in ${Math.ceil((count.startBeat - at) / count.clickBeats)}`;
1103
+ }
1104
+ } else if (at !== undefined) {
1105
+ // Meter changes: count the clicks of the bar the playhead is in.
1106
+ const tpb = score.ticksPerBeat;
1107
+ const position = barAt(score, loopTickAt(score, at));
1108
+ const click = clickTicksOf(position, tpb);
1109
+ const inBar = position.offset / click;
1110
+ const whole = Math.floor(inBar);
1111
+ beat = {
1112
+ index: whole + 1,
1113
+ of: Math.round(position.barTicks / click),
1114
+ flash:
1115
+ ((inBar - whole) * click * 60_000) /
1116
+ tpb /
1117
+ bpmAtTick(score, position.tick + whole * click) <
1118
+ FLASH_MS,
1119
+ };
802
1120
  }
803
1121
  return {
804
1122
  range: this.keyboard.range,
@@ -828,6 +1146,7 @@ export class PlaySession {
828
1146
  }
829
1147
 
830
1148
  public strip(now = this.host.now()): PlayStripKey[] {
1149
+ this.syncDegrees();
831
1150
  return stripCells(
832
1151
  this.keyboard,
833
1152
  this.keyboard.litKeys(now),
@@ -929,14 +1248,16 @@ export function recordOperations(
929
1248
  }>,
930
1249
  ): ScoreOperation[] {
931
1250
  const operations: ScoreOperation[] = [];
932
- const loopBeats = score.bars * score.beatsPerBar;
933
1251
  const tpb = score.ticksPerBeat;
1252
+ const loopBeats = loopTicksOf(score) / tpb;
934
1253
  const erase = new Set(options.eraseBars ?? []);
935
1254
  if (erase.size > 0)
936
1255
  for (const note of score.notes) {
937
1256
  if (note.trackId !== options.trackId) continue;
938
1257
  if (options.keep?.has(note.id)) continue;
939
- const bar = Math.floor(note.startTick / tpb / score.beatsPerBar);
1258
+ const bar = hasMeterChanges(score)
1259
+ ? barAt(score, note.startTick).bar
1260
+ : Math.floor(note.startTick / tpb / score.beatsPerBar);
940
1261
  if (erase.has(bar))
941
1262
  operations.push({ type: "removeNote", noteId: note.id });
942
1263
  }
@@ -982,3 +1303,98 @@ export function recordOperations(
982
1303
  }
983
1304
  return operations;
984
1305
  }
1306
+
1307
+ /**
1308
+ * The `updateTrack` that merges recorded pedal events into the track,
1309
+ * wrapped into the loop (unquantized: pedalling is about the release), after
1310
+ * dropping existing events in `eraseBars` (replace). Undefined when nothing
1311
+ * changes.
1312
+ */
1313
+ export function recordPedalOperation(
1314
+ score: TrackScore,
1315
+ options: Readonly<{
1316
+ trackId: string;
1317
+ events: readonly { beat: number; state: PedalState }[];
1318
+ eraseBars?: readonly number[];
1319
+ /** The pedal as the take entered each erased bar (default `up`). */
1320
+ eraseStates?: ReadonlyMap<number, PedalState>;
1321
+ }>,
1322
+ ): ScoreOperation | undefined {
1323
+ const track = score.tracks.find((item) => item.id === options.trackId);
1324
+ if (!track) return undefined;
1325
+ const tpb = score.ticksPerBeat;
1326
+ // Bars follow the meter map; without meter changes this is the 0.4 grid.
1327
+ const maxTick = loopTicksOf(score);
1328
+ const original = track.pedal ?? [];
1329
+ const erase = new Set(options.eraseBars ?? []);
1330
+ const kept = original.filter(
1331
+ (event) => !erase.has(barAt(score, event.tick).bar),
1332
+ );
1333
+ if (options.events.length === 0 && erase.size === 0) return undefined;
1334
+ const byTick = new Map<number, PedalEvent>(
1335
+ kept.map((event) => [event.tick, event]),
1336
+ );
1337
+ // Replace: an erased bar starts in the state the take had there, and the
1338
+ // old pedal resumes after it, so no stale press is left without its lift.
1339
+ for (const bar of erase) {
1340
+ const start = barStartTick(score, bar);
1341
+ const end = barStartTick(score, bar + 1);
1342
+ byTick.set(start, {
1343
+ tick: start,
1344
+ state: options.eraseStates?.get(bar) ?? "up",
1345
+ });
1346
+ if (end < maxTick && !erase.has(bar + 1) && !byTick.has(end))
1347
+ byTick.set(end, { tick: end, state: pedalStateAt(original, end - 1) });
1348
+ }
1349
+ for (const event of options.events) {
1350
+ let tick = Math.round(event.beat * tpb) % maxTick;
1351
+ if (tick < 0) tick += maxTick;
1352
+ byTick.set(tick, { tick, state: event.state });
1353
+ }
1354
+ const sorted = [...byTick.values()].sort((a, b) => a.tick - b.tick);
1355
+ // A pedal held across the loop seam is down again from tick 0.
1356
+ const seam = sorted[sorted.length - 1]?.state ?? "up";
1357
+ if (options.events.length > 0 && seam !== "up" && sorted[0]?.tick !== 0)
1358
+ sorted.unshift({ tick: 0, state: seam });
1359
+ // Drop events that do not change the state (the lane has a size limit).
1360
+ const compact: PedalEvent[] = [];
1361
+ for (const event of sorted) {
1362
+ const before = compact[compact.length - 1]?.state ?? "up";
1363
+ if (event.state !== before) compact.push(event);
1364
+ }
1365
+ if (compact.length > EXPRESSION_LIMITS.maxPedalEvents)
1366
+ throw new ExpressionValidationError(
1367
+ `the pedal lane is full (${EXPRESSION_LIMITS.maxPedalEvents} events)`,
1368
+ );
1369
+ const pedal = normalizePedal(compact, maxTick);
1370
+ const same =
1371
+ (pedal?.length ?? 0) === original.length &&
1372
+ (pedal ?? []).every(
1373
+ (event, index) =>
1374
+ event.tick === original[index]!.tick &&
1375
+ event.state === original[index]!.state,
1376
+ );
1377
+ if (same) return undefined;
1378
+ return {
1379
+ type: "updateTrack",
1380
+ trackId: options.trackId,
1381
+ patch: { pedal: pedal && pedal.length > 0 ? pedal : null },
1382
+ };
1383
+ }
1384
+
1385
+ /** Whether `track` sounds in plain 12-TET (no tuning, or one equal to it). */
1386
+ function twelveTet(
1387
+ score: Readonly<{ tuning?: Tuning; key?: string | null }>,
1388
+ track: Readonly<{ tuning?: Tuning }> | undefined,
1389
+ ): boolean {
1390
+ if (!score.tuning && !track?.tuning) return true;
1391
+ const table = resolveTuning(score.tuning, track?.tuning, score.key);
1392
+ if (!table) return true;
1393
+ if (table.size !== 12 || Math.abs(table.period - 1200) > 0.01) return false;
1394
+ for (let key = 1; key < 128; key += 1) {
1395
+ const [low, high] = [table.hz[key - 1]!, table.hz[key]!];
1396
+ if (low <= 0 || high <= 0) return false;
1397
+ if (Math.abs(1200 * Math.log2(high / low) - 100) > 0.5) return false;
1398
+ }
1399
+ return true;
1400
+ }