@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
@@ -0,0 +1,1241 @@
1
+ /**
2
+ * Expressive performance (dawg 0.5): per-note articulation, glide, bend and
3
+ * vibrato, and per-track glide, sustain pedal, velocity curve and humanize.
4
+ *
5
+ * Every field is optional and absent means today's behaviour: a track and
6
+ * notes without any of them come back from `performNotes` as the very same
7
+ * array, so 0.4 projects render byte-identically.
8
+ *
9
+ * The score stays clean: humanize, pedal extension and articulation are
10
+ * applied at render time to copies of the notes (`PerformedNote`), never
11
+ * written back. Precedence with the synth voice (`core/synth.ts`): a note's
12
+ * `vibrato` replaces the synth `vib`/`vibmod`, a note's `bend` replaces the
13
+ * pitch envelope (`penv`), and a gliding note ignores the ZzFX `slide`.
14
+ */
15
+ import type { Note, Track, TrackScore } from "./score.ts";
16
+ import { keyCentsFor, resolveTuning } from "./tuning.ts";
17
+ import {
18
+ hasTempoMap,
19
+ loopTicksOf,
20
+ secondsAtTick,
21
+ type TimeScore,
22
+ } from "./tempo.ts";
23
+
24
+ export const EXPRESSION_LIMITS = Object.freeze({
25
+ maxBendPoints: 32,
26
+ /** |cents| of a bend point (four octaves). */
27
+ maxBendCents: 4800,
28
+ /** Vibrato rate in Hz. */
29
+ maxVibratoRate: 20,
30
+ /** Vibrato depth in cents (peak deviation either side). */
31
+ maxVibratoDepth: 1200,
32
+ /** Vibrato delay in seconds. */
33
+ maxVibratoDelay: 10,
34
+ /** Glide time in seconds, note or track. */
35
+ maxGlideSeconds: 10,
36
+ maxPedalEvents: 1024,
37
+ /** Humanize timing spread in milliseconds (either side). */
38
+ maxHumanizeTimingMs: 250,
39
+ /** Humanize velocity and length spread in percent. */
40
+ maxHumanizePercent: 100,
41
+ maxSeed: 2_147_483_647,
42
+ });
43
+
44
+ export const ARTICULATIONS = [
45
+ "staccato",
46
+ "legato",
47
+ "accent",
48
+ "tenuto",
49
+ "marcato",
50
+ "ghost",
51
+ ] as const;
52
+ export type Articulation = (typeof ARTICULATIONS)[number];
53
+
54
+ /**
55
+ * What each articulation does at render: the sounding length is
56
+ * `length ×` the written length, velocity becomes
57
+ * `clamp(velocity × scale + add)`. `legato` also holds the note until the
58
+ * next note on the track starts (closing a gap of up to one beat) and
59
+ * overlaps it by a 64th note, so a legato glide track slides into it.
60
+ * Values follow notation-playback conventions (MuseScore: staccato 50%
61
+ * gate, marcato about two thirds and louder; tenuto full value).
62
+ */
63
+ export const ARTICULATION_EFFECTS: Readonly<
64
+ Record<
65
+ Articulation,
66
+ Readonly<{ length: number; scale: number; add: number; doc: string }>
67
+ >
68
+ > = Object.freeze({
69
+ staccato: { length: 0.5, scale: 1, add: 0, doc: "half length" },
70
+ legato: {
71
+ length: 1,
72
+ scale: 1,
73
+ add: 0,
74
+ doc: "held into the next note with a short overlap",
75
+ },
76
+ accent: { length: 1, scale: 1, add: 0.2, doc: "velocity +0.2" },
77
+ tenuto: {
78
+ length: 1,
79
+ scale: 1,
80
+ add: 0.05,
81
+ doc: "full length, velocity +0.05",
82
+ },
83
+ marcato: {
84
+ length: 2 / 3,
85
+ scale: 1,
86
+ add: 0.3,
87
+ doc: "two-thirds length, velocity +0.3",
88
+ },
89
+ ghost: {
90
+ length: 0.5,
91
+ scale: 0.4,
92
+ add: 0,
93
+ doc: "half length, velocity ×0.4",
94
+ },
95
+ });
96
+
97
+ export type BendPoint = Readonly<{
98
+ /**
99
+ * Position in the note, 0 (start) .. 1 (end of its articulated length,
100
+ * before sustain pedal and humanize; the last value holds after it).
101
+ */
102
+ at: number;
103
+ /** Pitch offset in cents, ±4800. */
104
+ cents: number;
105
+ }>;
106
+
107
+ export type NoteVibrato = Readonly<{
108
+ /** Hz, > 0. */
109
+ rate: number;
110
+ /** Peak deviation in cents either side, 0..1200. */
111
+ depth: number;
112
+ /** Seconds before the vibrato starts (it then fades in over 0.15 s). */
113
+ delay?: number;
114
+ }>;
115
+
116
+ /** The expression fields a note may carry (all optional). */
117
+ export type NoteExpression = Readonly<{
118
+ articulation?: Articulation;
119
+ /** Portamento into this note from the previous pitch, seconds; 0 never glides. */
120
+ glide?: number;
121
+ bend?: readonly BendPoint[];
122
+ vibrato?: NoteVibrato;
123
+ /**
124
+ * This note's humanize amounts, replacing the track's (seeded by the
125
+ * track's humanize seed, 1 when it has none). `{}` keeps the note exact.
126
+ */
127
+ humanize?: NoteHumanize;
128
+ }>;
129
+
130
+ /** Per-note humanize: amounts as in `Humanize`, absent ones 0. */
131
+ export type NoteHumanize = Readonly<{
132
+ timing?: number;
133
+ velocity?: number;
134
+ length?: number;
135
+ }>;
136
+
137
+ /** Patch for `setNoteExpression`: `null` clears a field. */
138
+ export type NoteExpressionPatch = Readonly<{
139
+ articulation?: Articulation | null;
140
+ glide?: number | null;
141
+ bend?: readonly BendPoint[] | null;
142
+ vibrato?: NoteVibrato | null;
143
+ humanize?: NoteHumanize | null;
144
+ }>;
145
+
146
+ export const NOTE_EXPRESSION_FIELDS = [
147
+ "articulation",
148
+ "glide",
149
+ "bend",
150
+ "vibrato",
151
+ "humanize",
152
+ ] as const;
153
+
154
+ export const GLIDE_MODES = ["legato", "mono", "poly"] as const;
155
+ export type GlideMode = (typeof GLIDE_MODES)[number];
156
+
157
+ /**
158
+ * A track's glide default. `legato` (the default) is monophonic and glides
159
+ * into a note that overlaps the previous one without retriggering its
160
+ * envelope (a note with its own glide also bridges a gap of up to a 16th,
161
+ * the TB-303 slide, flagged on the destination note); `mono` is monophonic, always glides and
162
+ * retriggers; `poly` keeps every voice and glides each note from the
163
+ * matching note of the previous chord.
164
+ */
165
+ export type TrackGlide = Readonly<{ time: number; mode: GlideMode }>;
166
+
167
+ export const DEFAULT_GLIDE_SECONDS = 0.06;
168
+
169
+ export const PEDAL_STATES = ["down", "half", "up"] as const;
170
+ export type PedalState = (typeof PEDAL_STATES)[number];
171
+
172
+ /** A sustain pedal (MIDI CC64) change at a tick. */
173
+ export type PedalEvent = Readonly<{ tick: number; state: PedalState }>;
174
+
175
+ export const VELOCITY_CURVES = ["linear", "soft", "hard", "fixed"] as const;
176
+ export type VelocityCurveName = (typeof VELOCITY_CURVES)[number];
177
+
178
+ /**
179
+ * How a track responds to velocity: `soft` (v^0.5: soft notes come out
180
+ * louder), `hard` (v^2: needs a firm touch), `fixed` (every note at
181
+ * `fixed`, default 0.8, like an organ). `linear` is the default and is
182
+ * never stored.
183
+ */
184
+ export type VelocityCurve = Readonly<{
185
+ curve: Exclude<VelocityCurveName, "linear">;
186
+ fixed?: number;
187
+ }>;
188
+
189
+ export const DEFAULT_FIXED_VELOCITY = 0.8;
190
+
191
+ /**
192
+ * Seeded, deterministic humanize applied at render: `timing` in ms either
193
+ * side, `velocity` in percent of full scale, `length` in percent of the
194
+ * note's length. Offsets follow a triangular distribution seeded by `seed`
195
+ * and the note id, so editing one note never reshuffles the others.
196
+ */
197
+ export type Humanize = Readonly<{
198
+ timing?: number;
199
+ velocity?: number;
200
+ length?: number;
201
+ seed: number;
202
+ }>;
203
+
204
+ /** Seconds of the half-pedal fade (time constant) and its cap (×5). */
205
+ export const HALF_PEDAL_TAU = 0.5;
206
+
207
+ /** Vibrato fade-in after its delay, seconds. */
208
+ export const VIBRATO_FADE_SECONDS = 0.15;
209
+
210
+ export class ExpressionValidationError extends Error {
211
+ constructor(message: string) {
212
+ super(message);
213
+ this.name = "ExpressionValidationError";
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Validation
219
+
220
+ function finite(value: unknown, label: string, min: number, max: number) {
221
+ if (
222
+ typeof value !== "number" ||
223
+ !Number.isFinite(value) ||
224
+ value < min ||
225
+ value > max
226
+ )
227
+ throw new ExpressionValidationError(
228
+ `${label} must be a number ${min}..${max}`,
229
+ );
230
+ return value;
231
+ }
232
+
233
+ function record(value: unknown, label: string): Record<string, unknown> {
234
+ if (typeof value !== "object" || value === null || Array.isArray(value))
235
+ throw new ExpressionValidationError(`${label} must be an object`);
236
+ return value as Record<string, unknown>;
237
+ }
238
+
239
+ function onlyKeys(
240
+ value: Record<string, unknown>,
241
+ keys: readonly string[],
242
+ label: string,
243
+ ): void {
244
+ for (const key of Object.keys(value))
245
+ if (!keys.includes(key))
246
+ throw new ExpressionValidationError(
247
+ `${label} has an unknown field ${key.slice(0, 32)}`,
248
+ );
249
+ }
250
+
251
+ export function normalizeArticulation(
252
+ value: unknown,
253
+ ): Articulation | undefined {
254
+ if (value === undefined || value === null) return undefined;
255
+ const name = typeof value === "string" ? value.trim().toLowerCase() : "";
256
+ const found = ARTICULATIONS.find(
257
+ (candidate) => candidate === name || candidate.slice(0, 4) === name,
258
+ );
259
+ if (!found)
260
+ throw new ExpressionValidationError(
261
+ `articulation must be one of ${ARTICULATIONS.join(", ")}`,
262
+ );
263
+ return found;
264
+ }
265
+
266
+ export function normalizeNoteGlide(value: unknown): number | undefined {
267
+ if (value === undefined || value === null) return undefined;
268
+ return finite(value, "note glide", 0, EXPRESSION_LIMITS.maxGlideSeconds);
269
+ }
270
+
271
+ export function normalizeBend(
272
+ value: unknown,
273
+ ): readonly BendPoint[] | undefined {
274
+ if (value === undefined || value === null) return undefined;
275
+ if (!Array.isArray(value) || value.length > EXPRESSION_LIMITS.maxBendPoints)
276
+ throw new ExpressionValidationError(
277
+ `bend must be an array of at most ${EXPRESSION_LIMITS.maxBendPoints} points`,
278
+ );
279
+ if (value.length === 0) return undefined;
280
+ const points = value.map((item: unknown) => {
281
+ const point = record(item, "bend point");
282
+ onlyKeys(point, ["at", "cents"], "bend point");
283
+ return Object.freeze({
284
+ at: finite(point.at, "bend at", 0, 1),
285
+ cents: finite(
286
+ point.cents,
287
+ "bend cents",
288
+ -EXPRESSION_LIMITS.maxBendCents,
289
+ EXPRESSION_LIMITS.maxBendCents,
290
+ ),
291
+ });
292
+ });
293
+ // Stable sort by position: equal positions keep their order (a jump).
294
+ points.sort((a, b) => a.at - b.at);
295
+ return Object.freeze(points);
296
+ }
297
+
298
+ export function normalizeVibrato(value: unknown): NoteVibrato | undefined {
299
+ if (value === undefined || value === null) return undefined;
300
+ const input = record(value, "vibrato");
301
+ onlyKeys(input, ["rate", "depth", "delay"], "vibrato");
302
+ const rate = finite(
303
+ input.rate ?? 5.5,
304
+ "vibrato rate",
305
+ 0.01,
306
+ EXPRESSION_LIMITS.maxVibratoRate,
307
+ );
308
+ const depth = finite(
309
+ input.depth ?? 20,
310
+ "vibrato depth",
311
+ 0,
312
+ EXPRESSION_LIMITS.maxVibratoDepth,
313
+ );
314
+ const delay =
315
+ input.delay === undefined
316
+ ? 0
317
+ : finite(
318
+ input.delay,
319
+ "vibrato delay",
320
+ 0,
321
+ EXPRESSION_LIMITS.maxVibratoDelay,
322
+ );
323
+ return Object.freeze({ rate, depth, ...(delay > 0 ? { delay } : {}) });
324
+ }
325
+
326
+ /** Validates a note's expression fields; only the set ones are returned. */
327
+ export function normalizeNoteExpression(
328
+ input: Readonly<Record<string, unknown>>,
329
+ ): NoteExpression {
330
+ const articulation = normalizeArticulation(input.articulation);
331
+ const glide = normalizeNoteGlide(input.glide);
332
+ const bend = normalizeBend(input.bend);
333
+ const vibrato = normalizeVibrato(input.vibrato);
334
+ const humanize = normalizeNoteHumanize(input.humanize);
335
+ return {
336
+ ...(articulation ? { articulation } : {}),
337
+ ...(glide !== undefined ? { glide } : {}),
338
+ ...(bend ? { bend } : {}),
339
+ ...(vibrato ? { vibrato } : {}),
340
+ ...(humanize ? { humanize } : {}),
341
+ };
342
+ }
343
+
344
+ /** A note's humanize amounts; zero amounts are dropped (`{}` = exact). */
345
+ export function normalizeNoteHumanize(
346
+ value: unknown,
347
+ ): NoteHumanize | undefined {
348
+ if (value === undefined || value === null) return undefined;
349
+ const input = record(value, "humanize");
350
+ onlyKeys(input, ["timing", "velocity", "length"], "humanize");
351
+ const amount = (key: "timing" | "velocity" | "length", max: number) =>
352
+ finite(input[key] ?? 0, `humanize ${key}`, 0, max);
353
+ const timing = amount("timing", EXPRESSION_LIMITS.maxHumanizeTimingMs);
354
+ const velocity = amount("velocity", EXPRESSION_LIMITS.maxHumanizePercent);
355
+ const length = amount("length", EXPRESSION_LIMITS.maxHumanizePercent);
356
+ return Object.freeze({
357
+ ...(timing > 0 ? { timing } : {}),
358
+ ...(velocity > 0 ? { velocity } : {}),
359
+ ...(length > 0 ? { length } : {}),
360
+ });
361
+ }
362
+
363
+ export function normalizeTrackGlide(value: unknown): TrackGlide | undefined {
364
+ if (value === undefined || value === null) return undefined;
365
+ if (typeof value === "number")
366
+ return Object.freeze({
367
+ time: finite(value, "glide time", 0, EXPRESSION_LIMITS.maxGlideSeconds),
368
+ mode: "legato" as const,
369
+ });
370
+ const input = record(value, "glide");
371
+ onlyKeys(input, ["time", "mode"], "glide");
372
+ const time = finite(
373
+ input.time ?? DEFAULT_GLIDE_SECONDS,
374
+ "glide time",
375
+ 0,
376
+ EXPRESSION_LIMITS.maxGlideSeconds,
377
+ );
378
+ const mode = input.mode ?? "legato";
379
+ if (!GLIDE_MODES.includes(mode as GlideMode))
380
+ throw new ExpressionValidationError(
381
+ `glide mode must be one of ${GLIDE_MODES.join(", ")}`,
382
+ );
383
+ return Object.freeze({ time, mode: mode as GlideMode });
384
+ }
385
+
386
+ export function normalizePedal(
387
+ value: unknown,
388
+ maxTick: number,
389
+ ): readonly PedalEvent[] | undefined {
390
+ if (value === undefined || value === null) return undefined;
391
+ if (!Array.isArray(value) || value.length > EXPRESSION_LIMITS.maxPedalEvents)
392
+ throw new ExpressionValidationError(
393
+ `pedal must be an array of at most ${EXPRESSION_LIMITS.maxPedalEvents} events`,
394
+ );
395
+ const byTick = new Map<number, PedalEvent>();
396
+ for (const item of value as unknown[]) {
397
+ const event = record(item, "pedal event");
398
+ onlyKeys(event, ["tick", "state"], "pedal event");
399
+ const tick = event.tick;
400
+ if (
401
+ typeof tick !== "number" ||
402
+ !Number.isInteger(tick) ||
403
+ tick < 0 ||
404
+ tick > maxTick
405
+ )
406
+ throw new ExpressionValidationError(
407
+ `pedal tick must be an integer 0..${maxTick}`,
408
+ );
409
+ if (!PEDAL_STATES.includes(event.state as PedalState))
410
+ throw new ExpressionValidationError(
411
+ `pedal state must be one of ${PEDAL_STATES.join(", ")}`,
412
+ );
413
+ // The last event at a tick wins.
414
+ byTick.set(tick, Object.freeze({ tick, state: event.state as PedalState }));
415
+ }
416
+ if (byTick.size === 0) return undefined;
417
+ return Object.freeze([...byTick.values()].sort((a, b) => a.tick - b.tick));
418
+ }
419
+
420
+ export function normalizeVelocityCurve(
421
+ value: unknown,
422
+ ): VelocityCurve | undefined {
423
+ if (value === undefined || value === null) return undefined;
424
+ const input =
425
+ typeof value === "string"
426
+ ? { curve: value }
427
+ : record(value, "velocityCurve");
428
+ onlyKeys(input, ["curve", "fixed"], "velocityCurve");
429
+ const curve = input.curve;
430
+ if (!VELOCITY_CURVES.includes(curve as VelocityCurveName))
431
+ throw new ExpressionValidationError(
432
+ `velocityCurve must be one of ${VELOCITY_CURVES.join(", ")}`,
433
+ );
434
+ if (curve === "linear") return undefined;
435
+ if (curve === "fixed")
436
+ return Object.freeze({
437
+ curve,
438
+ fixed: finite(
439
+ input.fixed ?? DEFAULT_FIXED_VELOCITY,
440
+ "velocityCurve fixed",
441
+ 0,
442
+ 1,
443
+ ),
444
+ });
445
+ if (input.fixed !== undefined)
446
+ throw new ExpressionValidationError(
447
+ "velocityCurve fixed is only used by the fixed curve",
448
+ );
449
+ return Object.freeze({ curve: curve as "soft" | "hard" });
450
+ }
451
+
452
+ export function normalizeHumanize(value: unknown): Humanize | undefined {
453
+ if (value === undefined || value === null) return undefined;
454
+ const input = record(value, "humanize");
455
+ onlyKeys(input, ["timing", "velocity", "length", "seed"], "humanize");
456
+ const timing = finite(
457
+ input.timing ?? 0,
458
+ "humanize timing",
459
+ 0,
460
+ EXPRESSION_LIMITS.maxHumanizeTimingMs,
461
+ );
462
+ const velocity = finite(
463
+ input.velocity ?? 0,
464
+ "humanize velocity",
465
+ 0,
466
+ EXPRESSION_LIMITS.maxHumanizePercent,
467
+ );
468
+ const length = finite(
469
+ input.length ?? 0,
470
+ "humanize length",
471
+ 0,
472
+ EXPRESSION_LIMITS.maxHumanizePercent,
473
+ );
474
+ const seed = input.seed ?? 1;
475
+ if (
476
+ typeof seed !== "number" ||
477
+ !Number.isInteger(seed) ||
478
+ seed < 0 ||
479
+ seed > EXPRESSION_LIMITS.maxSeed
480
+ )
481
+ throw new ExpressionValidationError(
482
+ `humanize seed must be an integer 0..${EXPRESSION_LIMITS.maxSeed}`,
483
+ );
484
+ if (timing === 0 && velocity === 0 && length === 0) return undefined;
485
+ return Object.freeze({
486
+ ...(timing > 0 ? { timing } : {}),
487
+ ...(velocity > 0 ? { velocity } : {}),
488
+ ...(length > 0 ? { length } : {}),
489
+ seed,
490
+ });
491
+ }
492
+
493
+ export const TRACK_PERFORMANCE_FIELDS = [
494
+ "glide",
495
+ "pedal",
496
+ "velocityCurve",
497
+ "humanize",
498
+ ] as const;
499
+
500
+ /** The performance fields a track may carry (all optional). */
501
+ export type TrackPerformance = Readonly<{
502
+ glide?: TrackGlide;
503
+ pedal?: readonly PedalEvent[];
504
+ velocityCurve?: VelocityCurve;
505
+ humanize?: Humanize;
506
+ }>;
507
+
508
+ export function normalizeTrackPerformance(
509
+ input: Readonly<Record<string, unknown>>,
510
+ maxTick: number,
511
+ ): TrackPerformance {
512
+ const glide = normalizeTrackGlide(input.glide);
513
+ const pedal = normalizePedal(input.pedal, maxTick);
514
+ const velocityCurve = normalizeVelocityCurve(input.velocityCurve);
515
+ const humanize = normalizeHumanize(input.humanize);
516
+ return {
517
+ ...(glide ? { glide } : {}),
518
+ ...(pedal ? { pedal } : {}),
519
+ ...(velocityCurve ? { velocityCurve } : {}),
520
+ ...(humanize ? { humanize } : {}),
521
+ };
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Velocity curve
526
+
527
+ export function curveVelocity(
528
+ curve: VelocityCurve | undefined,
529
+ velocity: number,
530
+ ): number {
531
+ const v = Math.max(0, Math.min(1, velocity));
532
+ if (!curve) return v;
533
+ if (curve.curve === "soft") return Math.sqrt(v);
534
+ if (curve.curve === "hard") return v * v;
535
+ return curve.fixed ?? DEFAULT_FIXED_VELOCITY;
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Pedal
540
+
541
+ /** The pedal state at `tick` (events at the tick count), `up` before any. */
542
+ export function pedalStateAt(
543
+ pedal: readonly PedalEvent[] | undefined,
544
+ tick: number,
545
+ ): PedalState {
546
+ let state: PedalState = "up";
547
+ for (const event of pedal ?? []) {
548
+ if (event.tick > tick) break;
549
+ state = event.state;
550
+ }
551
+ return state;
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // Performance
556
+
557
+ /** One stretch of a (possibly legato-chained) note's pitch curve. */
558
+ export type PitchSegment = Readonly<{
559
+ /** Seconds from the performed note's start. */
560
+ offset: number;
561
+ /** Seconds the segment's own note sounds (bend positions scale to it). */
562
+ length: number;
563
+ /** Target pitch in cents relative to the performed note's pitch. */
564
+ target: number;
565
+ /** Glide into `target` from `from` over `glide` seconds (0: none). */
566
+ from: number;
567
+ glide: number;
568
+ /**
569
+ * `exp`: a constant-time RC approach (time constant glide/3, landing on
570
+ * the target at `glide`), as an analog portamento or a TB-303 slide;
571
+ * absent: a linear sweep in cents.
572
+ */
573
+ curve?: "exp";
574
+ bend?: readonly BendPoint[];
575
+ vibrato?: NoteVibrato;
576
+ /** Seconds the vibrato's phase counts from (its own note's start). */
577
+ vibratoFrom: number;
578
+ }>;
579
+
580
+ /** Render-only additions to a note; absent means it plays as written. */
581
+ export type NotePerformance = Readonly<{
582
+ /** Pitch offset in cents at `t` seconds from the note start. */
583
+ cents?: (t: number) => number;
584
+ /** Half pedal: from `from` seconds the level fades with time constant `tau`. */
585
+ damp?: Readonly<{ from: number; tau: number }>;
586
+ /** The note's vibrato replaces the synth `vib`/`vibmod`. */
587
+ replaceVibrato: boolean;
588
+ /** The note's bend replaces the synth pitch envelope (`penv`). */
589
+ replacePitchEnvelope: boolean;
590
+ /** The note glides, so the ZzFX `slide` is ignored. */
591
+ replaceSlide: boolean;
592
+ /**
593
+ * Accent or marcato: synth voices open the filter (`faccent` octaves on
594
+ * the cutoff and envelope depth, shorter filter decay), TB-303 style.
595
+ */
596
+ accent?: boolean;
597
+ }>;
598
+
599
+ export type PerformedNote = Note & { readonly performance?: NotePerformance };
600
+
601
+ export type PerformanceTiming = Readonly<{
602
+ tempoBpm: number;
603
+ ticksPerBeat: number;
604
+ /** Ticks a sustained note may ring to (the loop end). */
605
+ endTick: number;
606
+ /**
607
+ * Seconds at a score tick, through the song's tempo map and fermatas.
608
+ * Absent means one constant `tempoBpm` (the 0.4 arithmetic).
609
+ */
610
+ secondsAt?: (tick: number) => number;
611
+ /**
612
+ * Cents of a key above A4 = 440 Hz in the track's tuning, so glides and
613
+ * legato chains move by the tuned interval. Absent is 12-TET.
614
+ */
615
+ keyCents?: (pitch: number) => number;
616
+ }>;
617
+
618
+ /**
619
+ * The timing `performNotes` needs for a song: notes ring to the loop end
620
+ * (through meter changes), and glides, bends, half pedal and humanize
621
+ * convert seconds through the tempo map when the song has one.
622
+ */
623
+ export function performanceTimingFor(score: TimeScore): PerformanceTiming {
624
+ return {
625
+ tempoBpm: score.tempoBpm,
626
+ ticksPerBeat: score.ticksPerBeat,
627
+ endTick: loopTicksOf(score),
628
+ ...(hasTempoMap(score)
629
+ ? { secondsAt: (tick: number) => secondsAtTick(score, tick) }
630
+ : {}),
631
+ };
632
+ }
633
+
634
+ /**
635
+ * `timing` with a track's merged tuning, so glides move by tuned steps.
636
+ * Without a song or track tuning it returns `timing` unchanged.
637
+ */
638
+ export function tunedTiming(
639
+ timing: PerformanceTiming,
640
+ score: Pick<TrackScore, "tuning" | "key">,
641
+ track: Track | undefined,
642
+ ): PerformanceTiming {
643
+ const tuning = resolveTuning(score.tuning, track?.tuning, score.key);
644
+ return tuning ? { ...timing, keyCents: keyCentsFor(tuning) } : timing;
645
+ }
646
+
647
+ /** Seconds between two ticks: through the tempo map, or at one tempo. */
648
+ type Span = (from: number, to: number) => number;
649
+
650
+ /** Cents from note `b` up to note `a`, with each note's own cents. */
651
+ type Interval = (a: Note, b: Note) => number;
652
+
653
+ function intervalFor(keyCents: PerformanceTiming["keyCents"]): Interval {
654
+ // Without a tuning this is exactly the 0.4 `(a − b) · 100` for notes
655
+ // without cents (adding 0 changes no float).
656
+ const keys = keyCents
657
+ ? (a: Note, b: Note) => keyCents(a.pitch) - keyCents(b.pitch)
658
+ : (a: Note, b: Note) => (a.pitch - b.pitch) * 100;
659
+ return (a, b) => keys(a, b) + ((a.cents ?? 0) - (b.cents ?? 0));
660
+ }
661
+
662
+ export function hasNoteExpression(note: Note): boolean {
663
+ return (
664
+ note.articulation !== undefined ||
665
+ note.glide !== undefined ||
666
+ note.bend !== undefined ||
667
+ note.vibrato !== undefined ||
668
+ note.humanize !== undefined
669
+ );
670
+ }
671
+
672
+ export function hasTrackPerformance(track: Track | undefined): boolean {
673
+ return (
674
+ track !== undefined &&
675
+ (track.glide !== undefined ||
676
+ track.pedal !== undefined ||
677
+ track.velocityCurve !== undefined ||
678
+ track.humanize !== undefined)
679
+ );
680
+ }
681
+
682
+ /** FNV-1a seeded generator (same family as `src/audio/random.ts`). */
683
+ function seeded(seed: string): () => number {
684
+ let hash = 0x811c9dc5;
685
+ for (let index = 0; index < seed.length; index += 1) {
686
+ hash ^= seed.charCodeAt(index);
687
+ hash = Math.imul(hash, 0x01000193) >>> 0;
688
+ }
689
+ let state = hash;
690
+ return () => {
691
+ state = (state + 0x6d2b79f5) >>> 0;
692
+ let value = Math.imul(state ^ (state >>> 15), 1 | state);
693
+ value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
694
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
695
+ };
696
+ }
697
+
698
+ /** Triangular in -1..1, peaked at 0. */
699
+ function triangular(random: () => number): number {
700
+ return random() + random() - 1;
701
+ }
702
+
703
+ type Working = {
704
+ note: Note;
705
+ start: number;
706
+ duration: number;
707
+ velocity: number;
708
+ damp?: { from: number; tau: number };
709
+ /** Humanize timing offset in ticks, applied after the glide structure. */
710
+ shift: number;
711
+ /** Humanize length factor (1: none). */
712
+ stretch: number;
713
+ /** The end is held by the sustain pedal (it stays at the lift). */
714
+ pedalled?: boolean;
715
+ /** Ticks bend positions scale to: the length after articulation. */
716
+ bendLength: number;
717
+ };
718
+
719
+ /** Articulations that accent a note (`NotePerformance.accent`). */
720
+ const ACCENTED: ReadonlySet<string> = new Set(["accent", "marcato"]);
721
+
722
+ /**
723
+ * The notes of one track as they are performed: articulation, humanize
724
+ * velocity, sustain pedal, glide (with monophonic legato chains, on the
725
+ * written timing), humanize timing and length, and the velocity curve. Returns `notes` itself when neither the track nor any note uses
726
+ * expression. Ticks in the result may be fractional (humanize timing).
727
+ */
728
+ export function performNotes(
729
+ track: Track | undefined,
730
+ notes: readonly Note[],
731
+ timing: PerformanceTiming,
732
+ ): readonly PerformedNote[] {
733
+ if (!hasTrackPerformance(track) && !notes.some(hasNoteExpression))
734
+ return notes;
735
+ const { tempoBpm, ticksPerBeat } = timing;
736
+ const secondsPerTick = 60 / (tempoBpm * ticksPerBeat);
737
+ const { secondsAt } = timing;
738
+ const span: Span = secondsAt
739
+ ? (from, to) => secondsAt(to) - secondsAt(from)
740
+ : (from, to) => (to - from) * secondsPerTick;
741
+ // Seconds per tick at `tick` (the local tempo).
742
+ const localSecondsPerTick = (tick: number): number =>
743
+ secondsAt ? secondsAt(tick + 1) - secondsAt(tick) : secondsPerTick;
744
+ const ordered = [...notes].sort(
745
+ (a, b) =>
746
+ a.startTick - b.startTick ||
747
+ a.pitch - b.pitch ||
748
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
749
+ );
750
+ const onsets = [...new Set(ordered.map((note) => note.startTick))];
751
+ // `onsets` is sorted, so the first onset after `tick` is a binary search.
752
+ const nextOnset = (tick: number): number | undefined => {
753
+ let lo = 0;
754
+ let hi = onsets.length;
755
+ while (lo < hi) {
756
+ const mid = (lo + hi) >>> 1;
757
+ if (onsets[mid]! > tick) hi = mid;
758
+ else lo = mid + 1;
759
+ }
760
+ return onsets[lo];
761
+ };
762
+ // 1. Articulation.
763
+ let working: Working[] = ordered.map((note) => {
764
+ const effect = note.articulation
765
+ ? ARTICULATION_EFFECTS[note.articulation]
766
+ : undefined;
767
+ let duration = note.durationTicks;
768
+ let velocity = note.velocity;
769
+ if (effect) {
770
+ duration = Math.max(1, duration * effect.length);
771
+ velocity = Math.max(0, Math.min(1, velocity * effect.scale + effect.add));
772
+ if (note.articulation === "legato") {
773
+ const overlap = ticksPerBeat / 16;
774
+ const next = nextOnset(note.startTick);
775
+ const end = note.startTick + duration;
776
+ if (next !== undefined && next - end <= ticksPerBeat)
777
+ duration = Math.max(duration, next - note.startTick + overlap);
778
+ else duration += overlap;
779
+ }
780
+ }
781
+ return {
782
+ note,
783
+ start: note.startTick,
784
+ duration,
785
+ velocity,
786
+ shift: 0,
787
+ stretch: 1,
788
+ bendLength: duration,
789
+ };
790
+ });
791
+ // 2. Humanize (seeded per note id, so edits never reshuffle the rest).
792
+ // Velocity applies now; the timing and length offsets are only stored and
793
+ // applied to the performed voices after step 4, so chords, the mono line,
794
+ // legato chains and pedal releases are all decided on the written timing.
795
+ // A note's own humanize replaces the track's amounts (same seed).
796
+ const trackHumanize = track?.humanize;
797
+ const seed = trackHumanize?.seed ?? 1;
798
+ const humanizing = working.some(
799
+ (item) =>
800
+ (item.note.humanize ?? trackHumanize) !== undefined &&
801
+ Object.keys(item.note.humanize ?? trackHumanize ?? {}).some(
802
+ (key) => key !== "seed",
803
+ ),
804
+ );
805
+ if (humanizing) {
806
+ working = working.map((item) => {
807
+ const ticksPerMs = 1 / (localSecondsPerTick(item.start) * 1000);
808
+ const humanize: NoteHumanize | undefined =
809
+ item.note.humanize ?? trackHumanize;
810
+ if (!humanize) return item;
811
+ const random = seeded(`${seed}:${item.note.id}`);
812
+ const shift = triangular(random) * (humanize.timing ?? 0) * ticksPerMs;
813
+ const velocity =
814
+ item.velocity + (triangular(random) * (humanize.velocity ?? 0)) / 100;
815
+ const stretch = 1 + (triangular(random) * (humanize.length ?? 0)) / 100;
816
+ return {
817
+ ...item,
818
+ shift,
819
+ stretch,
820
+ velocity: Math.max(0, Math.min(1, velocity)),
821
+ };
822
+ });
823
+ }
824
+ // 3. Sustain pedal: a key released while the pedal is down rings until
825
+ // the pedal lifts; under half pedal it fades. Re-striking the same pitch
826
+ // stops the ringing one (the string is struck again).
827
+ const pedal = track?.pedal;
828
+ if (pedal) {
829
+ const ups = pedal.filter((event) => event.state === "up");
830
+ working = working.map((item) => {
831
+ const release = item.start + item.duration;
832
+ const state = pedalStateAt(pedal, release);
833
+ if (state === "up") return item;
834
+ const lift =
835
+ ups.find((event) => event.tick > release)?.tick ?? timing.endTick;
836
+ let end = Math.max(release, Math.min(lift, timing.endTick));
837
+ // Half pedal, at the release or later while held, lets the note fade.
838
+ const halfAt =
839
+ state === "half"
840
+ ? release
841
+ : pedal.find(
842
+ (event) =>
843
+ event.state === "half" &&
844
+ event.tick > release &&
845
+ event.tick < end,
846
+ )?.tick;
847
+ let damp: Working["damp"];
848
+ if (halfAt !== undefined) {
849
+ const tauTicks = HALF_PEDAL_TAU / localSecondsPerTick(halfAt);
850
+ end = Math.min(end, halfAt + 5 * tauTicks);
851
+ damp = {
852
+ from: span(item.start, halfAt),
853
+ tau: HALF_PEDAL_TAU,
854
+ };
855
+ }
856
+ const restrike = working.find(
857
+ (other) =>
858
+ other !== item &&
859
+ other.note.pitch === item.note.pitch &&
860
+ other.start > item.start &&
861
+ other.start < end,
862
+ );
863
+ if (restrike) end = Math.max(release, restrike.start);
864
+ return {
865
+ ...item,
866
+ duration: end - item.start,
867
+ pedalled: end > release,
868
+ ...(damp ? { damp } : {}),
869
+ };
870
+ });
871
+ }
872
+ // 4. Glide and monophony (on the written timing).
873
+ let performed = glideAndMono(
874
+ track,
875
+ working,
876
+ span,
877
+ ticksPerBeat,
878
+ intervalFor(timing.keyCents),
879
+ );
880
+ // 4b. Humanize timing and length, applied to whole voices: a legato chain
881
+ // moves as one, and a pedalled end stays at the pedal lift. A note on tick
882
+ // 0 can only drift late (nothing sounds before the loop starts).
883
+ if (
884
+ humanizing &&
885
+ working.some((item) => item.shift !== 0 || item.stretch !== 1)
886
+ ) {
887
+ performed = performed.map((item) => {
888
+ const start = Math.max(0, item.start + item.shift);
889
+ const end = item.pedalled
890
+ ? item.start + item.duration
891
+ : start + item.duration * item.stretch;
892
+ return { ...item, start, duration: Math.max(1, end - start) };
893
+ });
894
+ if (track?.glide?.mode === "legato" || track?.glide?.mode === "mono") {
895
+ // One voice: a humanized note still stops where the next one starts.
896
+ const byStart = [...performed].sort((a, b) => a.start - b.start);
897
+ for (let k = 0; k + 1 < byStart.length; k += 1) {
898
+ const item = byStart[k]!;
899
+ const next = byStart[k + 1]!;
900
+ if (item.start + item.duration > next.start)
901
+ item.duration = Math.max(1, next.start - item.start);
902
+ }
903
+ }
904
+ }
905
+ // 5. Velocity curve.
906
+ const curve = track?.velocityCurve;
907
+ return performed.map((item) => {
908
+ const velocity = curveVelocity(curve, item.velocity);
909
+ const performance = item.performance;
910
+ const changed =
911
+ item.start !== item.note.startTick ||
912
+ item.duration !== item.note.durationTicks ||
913
+ velocity !== item.note.velocity ||
914
+ performance !== undefined;
915
+ if (!changed) return item.note;
916
+ return Object.freeze({
917
+ ...item.note,
918
+ startTick: item.start,
919
+ durationTicks: item.duration,
920
+ velocity,
921
+ ...(performance ? { performance } : {}),
922
+ });
923
+ });
924
+ }
925
+
926
+ type Glided = Working & { performance?: NotePerformance };
927
+
928
+ function segmentFor(
929
+ item: Working,
930
+ offset: number,
931
+ target: number,
932
+ from: number,
933
+ glide: number,
934
+ span: Span,
935
+ vibratoFrom: number,
936
+ curve?: "exp",
937
+ ): PitchSegment {
938
+ const { note } = item;
939
+ return {
940
+ offset,
941
+ // Bends follow the key (the articulated length), not pedal or humanize.
942
+ length: span(item.start, item.start + item.bendLength),
943
+ target,
944
+ from,
945
+ glide,
946
+ ...(curve && glide > 0 ? { curve } : {}),
947
+ ...(note.bend ? { bend: note.bend } : {}),
948
+ ...(note.vibrato ? { vibrato: note.vibrato } : {}),
949
+ vibratoFrom,
950
+ };
951
+ }
952
+
953
+ function performanceFor(
954
+ segments: readonly PitchSegment[],
955
+ damp: Working["damp"],
956
+ accent = false,
957
+ ): NotePerformance | undefined {
958
+ const pitched = segments.some(
959
+ (segment) =>
960
+ segment.glide > 0 ||
961
+ segment.target !== 0 ||
962
+ segment.bend !== undefined ||
963
+ segment.vibrato !== undefined,
964
+ );
965
+ if (!pitched && !damp && !accent) return undefined;
966
+ // Voices ask for increasing `t`, so a cursor makes the segment lookup
967
+ // amortised O(1) even for a long legato chain (one voice, many segments).
968
+ let cursor = 0;
969
+ const cents = (t: number): number => {
970
+ if (t < segments[cursor]!.offset) cursor = 0;
971
+ cursor = segmentIndexAt(segments, t, cursor);
972
+ return centsOfSegment(segments[cursor]!, t);
973
+ };
974
+ return Object.freeze({
975
+ ...(pitched ? { cents } : {}),
976
+ ...(accent ? { accent: true } : {}),
977
+ ...(damp ? { damp: Object.freeze({ ...damp }) } : {}),
978
+ replaceVibrato: segments.some((segment) => segment.vibrato !== undefined),
979
+ replacePitchEnvelope: segments.some(
980
+ (segment) => segment.bend !== undefined,
981
+ ),
982
+ replaceSlide: segments.some(
983
+ (segment) => segment.glide > 0 || segment.target !== 0,
984
+ ),
985
+ });
986
+ }
987
+
988
+ /** Cents of a bend curve at `x` (0..1); starts from 0 unless a point is at 0. */
989
+ export function bendAt(points: readonly BendPoint[], x: number): number {
990
+ let previous: BendPoint = { at: 0, cents: 0 };
991
+ if (points[0] && points[0].at === 0) previous = points[0];
992
+ for (const point of points) {
993
+ if (point.at >= x) {
994
+ if (point.at === previous.at) return point.cents;
995
+ const mix = (x - previous.at) / (point.at - previous.at);
996
+ return previous.cents + (point.cents - previous.cents) * mix;
997
+ }
998
+ previous = point;
999
+ }
1000
+ return previous.cents;
1001
+ }
1002
+
1003
+ /** Vibrato in cents at `t` seconds after its note's start. */
1004
+ export function vibratoAt(vibrato: NoteVibrato, t: number): number {
1005
+ const into = t - (vibrato.delay ?? 0);
1006
+ if (into <= 0 || vibrato.depth === 0) return 0;
1007
+ const fade = Math.min(1, into / VIBRATO_FADE_SECONDS);
1008
+ return vibrato.depth * fade * Math.sin(2 * Math.PI * vibrato.rate * into);
1009
+ }
1010
+
1011
+ /** The last segment starting at or before `t`, scanning on from `from`. */
1012
+ function segmentIndexAt(
1013
+ segments: readonly PitchSegment[],
1014
+ t: number,
1015
+ from = 0,
1016
+ ): number {
1017
+ let index = from;
1018
+ while (index + 1 < segments.length && segments[index + 1]!.offset <= t)
1019
+ index += 1;
1020
+ return index;
1021
+ }
1022
+
1023
+ /** The pitch offset in cents at `t` seconds into a performed note. */
1024
+ export function centsAt(segments: readonly PitchSegment[], t: number): number {
1025
+ return centsOfSegment(segments[segmentIndexAt(segments, t)]!, t);
1026
+ }
1027
+
1028
+ /**
1029
+ * The glide and bend part of a segment at `t` (no vibrato): the pitch a
1030
+ * voice has reached, which the next legato segment glides on from.
1031
+ */
1032
+ function reachedCents(segment: PitchSegment, t: number): number {
1033
+ return centsOfSegment({ ...segment, vibrato: undefined }, t);
1034
+ }
1035
+
1036
+ function centsOfSegment(segment: PitchSegment, t: number): number {
1037
+ const local = t - segment.offset;
1038
+ let cents = segment.target;
1039
+ if (segment.glide > 0 && segment.curve === "exp" && local < segment.glide)
1040
+ // An RC approach with time constant glide/3, scaled to land exactly on
1041
+ // the target at `glide` so the pitch never steps.
1042
+ cents =
1043
+ segment.from +
1044
+ ((segment.target - segment.from) *
1045
+ (1 - Math.exp((-3 * Math.max(0, local)) / segment.glide))) /
1046
+ (1 - Math.exp(-3));
1047
+ else if (segment.glide > 0 && local < segment.glide)
1048
+ cents =
1049
+ segment.from + ((segment.target - segment.from) * local) / segment.glide;
1050
+ if (segment.bend)
1051
+ cents += bendAt(
1052
+ segment.bend,
1053
+ segment.length > 0 ? Math.min(1, local / segment.length) : 1,
1054
+ );
1055
+ if (segment.vibrato)
1056
+ cents += vibratoAt(segment.vibrato, t - segment.vibratoFrom);
1057
+ return cents;
1058
+ }
1059
+
1060
+ function glideAndMono(
1061
+ track: Track | undefined,
1062
+ working: readonly Working[],
1063
+ span: Span,
1064
+ ticksPerBeat: number,
1065
+ interval: Interval,
1066
+ ): Glided[] {
1067
+ const trackGlide = track?.glide;
1068
+ const mode = trackGlide?.mode;
1069
+ // Mono and legato glide approach the pitch exponentially, as an analog
1070
+ // portamento does; polyphonic glide stays a linear sweep.
1071
+ const curve = mode === "legato" || mode === "mono" ? "exp" : undefined;
1072
+ const glideOf = (item: Working): number =>
1073
+ item.note.glide ?? trackGlide?.time ?? 0;
1074
+ const accented = (item: Working): boolean =>
1075
+ item.note.articulation !== undefined &&
1076
+ ACCENTED.has(item.note.articulation);
1077
+ const single = (item: Working, from?: number, glide = 0): Glided => {
1078
+ const segments = [
1079
+ segmentFor(
1080
+ item,
1081
+ 0,
1082
+ 0,
1083
+ from ?? 0,
1084
+ from === undefined ? 0 : glide,
1085
+ span,
1086
+ 0,
1087
+ curve,
1088
+ ),
1089
+ ];
1090
+ const performance = performanceFor(segments, item.damp, accented(item));
1091
+ return { ...item, ...(performance ? { performance } : {}) };
1092
+ };
1093
+ if (mode === "legato" || mode === "mono") {
1094
+ // Monophonic: of notes starting together the highest plays, and a note
1095
+ // stops when the next one starts.
1096
+ const line: Working[] = [];
1097
+ for (const item of [...working].sort(
1098
+ (a, b) => a.start - b.start || b.note.pitch - a.note.pitch,
1099
+ )) {
1100
+ const last = line[line.length - 1];
1101
+ if (last && last.start === item.start) continue;
1102
+ line.push({ ...item });
1103
+ }
1104
+ const out: Glided[] = [];
1105
+ let monoSegment: PitchSegment | undefined;
1106
+ let index = 0;
1107
+ while (index < line.length) {
1108
+ const first = line[index]!;
1109
+ if (mode === "mono") {
1110
+ const next = line[index + 1];
1111
+ if (next && first.start + first.duration > next.start)
1112
+ first.duration = Math.max(1, next.start - first.start);
1113
+ const previous = line[index - 1];
1114
+ const glide = glideOf(first);
1115
+ let from: number | undefined;
1116
+ if (previous && glide > 0) {
1117
+ // From the pitch the previous voice reached (it may still be
1118
+ // gliding when it is cut off).
1119
+ const at = span(previous.start, first.start);
1120
+ const reached = monoSegment ? reachedCents(monoSegment, at) : 0;
1121
+ from = interval(previous.note, first.note) + reached;
1122
+ }
1123
+ const voice =
1124
+ from === undefined ? single(first) : single(first, from, glide);
1125
+ monoSegment = segmentFor(
1126
+ first,
1127
+ 0,
1128
+ 0,
1129
+ from ?? 0,
1130
+ from === undefined ? 0 : glide,
1131
+ span,
1132
+ 0,
1133
+ curve,
1134
+ );
1135
+ out.push(voice);
1136
+ index += 1;
1137
+ continue;
1138
+ }
1139
+ // Legato: overlapping notes chain into one voice that glides.
1140
+ const chain: Working[] = [first];
1141
+ while (index + chain.length < line.length) {
1142
+ const last = chain[chain.length - 1]!;
1143
+ const next = line[index + chain.length]!;
1144
+ // A note with its own glide slides into from the previous note:
1145
+ // the gate holds across a gap of up to one 16th step (a TB-303 gate
1146
+ // closes part-way through its step) and the pitch slides. `next` is
1147
+ // the next onset in the line, so a slide never bridges a rest with
1148
+ // another note in it; a longer gap is a rest and retriggers.
1149
+ const end = last.start + last.duration;
1150
+ const slide =
1151
+ (next.note.glide ?? 0) > 0 && next.start - end <= ticksPerBeat / 4;
1152
+ if ((end <= next.start && !slide) || next.note.glide === 0) break;
1153
+ chain.push(next);
1154
+ }
1155
+ // Cut each chained note at the next one's start; a note that does not
1156
+ // chain still ends where the next starts (one voice).
1157
+ const after = line[index + chain.length];
1158
+ const tail = chain[chain.length - 1]!;
1159
+ if (after && tail.start + tail.duration > after.start)
1160
+ tail.duration = Math.max(1, after.start - tail.start);
1161
+ if (chain.length === 1) {
1162
+ out.push(single(first));
1163
+ index += 1;
1164
+ continue;
1165
+ }
1166
+ const segments: PitchSegment[] = [];
1167
+ chain.forEach((item, k) => {
1168
+ const offset = span(first.start, item.start);
1169
+ const target = interval(item.note, first.note);
1170
+ // Glide on from the pitch the voice has reached, so a glide cut off
1171
+ // by the next note never jumps (a TB-303 slide is continuous).
1172
+ const prior = segments[k - 1];
1173
+ const from = prior ? reachedCents(prior, offset) : 0;
1174
+ const segment = segmentFor(
1175
+ item,
1176
+ offset,
1177
+ target,
1178
+ from,
1179
+ k === 0 ? 0 : glideOf(item),
1180
+ span,
1181
+ offset,
1182
+ curve,
1183
+ );
1184
+ const sounding =
1185
+ k + 1 < chain.length
1186
+ ? span(item.start, chain[k + 1]!.start)
1187
+ : segment.length;
1188
+ segments.push({
1189
+ ...segment,
1190
+ length: Math.min(segment.length, sounding),
1191
+ });
1192
+ });
1193
+ const end = tail.start + tail.duration;
1194
+ const merged: Working = {
1195
+ ...first,
1196
+ duration: end - first.start,
1197
+ stretch: 1,
1198
+ pedalled: tail.pedalled ?? false,
1199
+ ...(tail.damp
1200
+ ? {
1201
+ damp: {
1202
+ from: tail.damp.from + span(first.start, tail.start),
1203
+ tau: tail.damp.tau,
1204
+ },
1205
+ }
1206
+ : {}),
1207
+ };
1208
+ // One voice: it keeps the first note's velocity, and is accented
1209
+ // when any chained note is (later velocities are not followed).
1210
+ const performance = performanceFor(
1211
+ segments,
1212
+ merged.damp,
1213
+ chain.some(accented),
1214
+ );
1215
+ out.push({ ...merged, ...(performance ? { performance } : {}) });
1216
+ index += chain.length;
1217
+ }
1218
+ return out;
1219
+ }
1220
+ // Polyphonic: each note glides from the note of the previous chord with
1221
+ // the same rank (lowest to lowest, …), when a glide time applies.
1222
+ const chords = new Map<number, Working[]>();
1223
+ for (const item of working) {
1224
+ const chord = chords.get(item.start);
1225
+ if (chord) chord.push(item);
1226
+ else chords.set(item.start, [item]);
1227
+ }
1228
+ const starts = [...chords.keys()].sort((a, b) => a - b);
1229
+ for (const chord of chords.values())
1230
+ chord.sort((a, b) => a.note.pitch - b.note.pitch);
1231
+ return working.map((item) => {
1232
+ const glide = glideOf(item);
1233
+ if (glide <= 0) return single(item);
1234
+ const at = starts.indexOf(item.start);
1235
+ const previous = at > 0 ? chords.get(starts[at - 1]!)! : undefined;
1236
+ if (!previous) return single(item);
1237
+ const rank = chords.get(item.start)!.indexOf(item);
1238
+ const source = previous[Math.min(rank, previous.length - 1)]!;
1239
+ return single(item, interval(source.note, item.note), glide);
1240
+ });
1241
+ }