@hraness/dawg 0.4.1 → 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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  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/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
package/core/midi.ts ADDED
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Standard MIDI File (SMF) export.
3
+ *
4
+ * Format 1: track 0 is the conductor track with the time-signature (FF 58)
5
+ * and tempo (FF 51) meta events, then one track per score track with its
6
+ * notes. The file's division is the score's ticks per quarter, so score
7
+ * ticks are written as they are.
8
+ *
9
+ * SMF tempo is constant between events, so ramps are written as steps
10
+ * (every `rampStepTicks`, a sixteenth by default) and a fermata as a slower
11
+ * tempo over its beat. Each step's tempo is the exact average over the step,
12
+ * so every step boundary lands at the same second as in the rendered WAV.
13
+ *
14
+ * Notes are written as performed (core/expression.ts): articulation
15
+ * lengths and velocities, humanize, the velocity curve and mono/legato
16
+ * voicing. The sustain pedal is written as CC64 (127 down, 64 half, 0 up)
17
+ * while each note keeps its key length, as a real pedal recording does.
18
+ * Pitch expression (glide, bend, per-note vibrato) and per-note cents are
19
+ * written as pitch bend (range ±24 semitones via RPN 0) on notes that sound
20
+ * alone on their track; SMF pitch bend is per channel, so notes inside a
21
+ * chord play at their written pitch.
22
+ *
23
+ * Tuning (core/tuning.ts) is written with the MIDI Tuning Standard: each
24
+ * tuned track gets a real-time single-note tuning change SysEx (F0 7F 7F
25
+ * 08 02) carrying every key's frequency as a tuning program, selected on
26
+ * its channel with RPN 3 (tuning program select). Synths that honour MTS
27
+ * play the tuning; others play 12-TET keys. Section starts are FF 06
28
+ * marker events on the conductor track.
29
+ *
30
+ * Pure and dependency-free apart from the score modules.
31
+ */
32
+ import { isDrumInstrument } from "./drums.ts";
33
+ import {
34
+ performanceTimingFor,
35
+ performNotes,
36
+ type PedalState,
37
+ type PerformanceTiming,
38
+ type PerformedNote,
39
+ } from "./expression.ts";
40
+ import {
41
+ barStartTick,
42
+ clickTicksOf,
43
+ fermataSpan,
44
+ loopTicksOf,
45
+ meterSegments,
46
+ performedNotes,
47
+ timeMapFor,
48
+ type TimeScore,
49
+ } from "./tempo.ts";
50
+ import type { TrackScore } from "./score.ts";
51
+ import { resolveTuning, type TuningTable } from "./tuning.ts";
52
+
53
+ export type MidiOptions = Readonly<{
54
+ /** Ticks per tempo step inside a ramp; default a sixteenth note. */
55
+ rampStepTicks?: number;
56
+ }>;
57
+
58
+ const MAX_DIVISION = 0x7fff;
59
+
60
+ /** Breakpoints and tempos (microseconds per quarter) for the conductor. */
61
+ export function midiTempoEvents(
62
+ score: TimeScore,
63
+ options: MidiOptions = {},
64
+ ): readonly Readonly<{ tick: number; usPerQuarter: number }>[] {
65
+ const tpb = score.ticksPerBeat;
66
+ const map = timeMapFor(score);
67
+ const constantUs = (bpm: number) => Math.round(60_000_000 / bpm);
68
+ if (!map) return [{ tick: 0, usPerQuarter: constantUs(score.tempoBpm) }];
69
+ const end = loopTicksOf(score);
70
+ const step = Math.max(1, Math.round(options.rampStepTicks ?? tpb / 4));
71
+ const points = new Set<number>([0]);
72
+ let previous = 0;
73
+ for (const event of score.time?.tempo ?? []) {
74
+ if (event.ramp)
75
+ for (let tick = previous + step; tick < event.tick; tick += step)
76
+ points.add(tick);
77
+ points.add(event.tick);
78
+ previous = event.tick;
79
+ }
80
+ for (const fermata of score.time?.fermatas ?? []) {
81
+ points.add(fermata.tick);
82
+ points.add(fermata.tick + fermataSpan(score, fermata.tick));
83
+ }
84
+ const ticks = [...points].filter((tick) => tick >= 0).sort((a, b) => a - b);
85
+ const out: { tick: number; usPerQuarter: number }[] = [];
86
+ for (let index = 0; index < ticks.length; index += 1) {
87
+ const tick = Math.round(ticks[index]!);
88
+ const next = ticks[index + 1];
89
+ let us: number;
90
+ if (next === undefined || tick >= end) us = constantUs(map.bpm(tick));
91
+ else {
92
+ const seconds = map.seconds(next) - map.seconds(tick);
93
+ us = Math.round((seconds * 1_000_000 * tpb) / (next - tick));
94
+ }
95
+ us = Math.min(0xffffff, Math.max(1, us));
96
+ if (out.length > 0 && out[out.length - 1]!.usPerQuarter === us) continue;
97
+ out.push({ tick, usPerQuarter: us });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function varLen(value: number): number[] {
103
+ let v = Math.max(0, Math.round(value));
104
+ const bytes = [v & 0x7f];
105
+ v >>>= 7;
106
+ while (v > 0) {
107
+ bytes.unshift((v & 0x7f) | 0x80);
108
+ v >>>= 7;
109
+ }
110
+ return bytes;
111
+ }
112
+
113
+ type TimedEvent = Readonly<{ tick: number; order: number; bytes: number[] }>;
114
+
115
+ function trackChunk(
116
+ events: TimedEvent[],
117
+ name: string | undefined,
118
+ endTick: number,
119
+ ): number[] {
120
+ const sorted = [...events].sort(
121
+ (a, b) => a.tick - b.tick || a.order - b.order,
122
+ );
123
+ const body: number[] = [];
124
+ if (name !== undefined) {
125
+ const text = [...new TextEncoder().encode(name)].slice(0, 127);
126
+ body.push(0, 0xff, 0x03, ...varLen(text.length), ...text);
127
+ }
128
+ let last = 0;
129
+ for (const event of sorted) {
130
+ body.push(...varLen(event.tick - last), ...event.bytes);
131
+ last = event.tick;
132
+ }
133
+ body.push(...varLen(Math.max(0, endTick - last)), 0xff, 0x2f, 0);
134
+ return [
135
+ 0x4d,
136
+ 0x54,
137
+ 0x72,
138
+ 0x6b,
139
+ (body.length >>> 24) & 0xff,
140
+ (body.length >>> 16) & 0xff,
141
+ (body.length >>> 8) & 0xff,
142
+ body.length & 0xff,
143
+ ...body,
144
+ ];
145
+ }
146
+
147
+ const CC64: Readonly<Record<PedalState, number>> = {
148
+ down: 127,
149
+ half: 64,
150
+ up: 0,
151
+ };
152
+
153
+ function log2(value: number): number {
154
+ return Math.round(Math.log2(value));
155
+ }
156
+
157
+ /** MTS frequency words (semitone, 14-bit fraction) for keys 0..127. */
158
+ export function mtsKeys(table: TuningTable): readonly number[][] {
159
+ const out: number[][] = [];
160
+ for (let key = 0; key < 128; key++) {
161
+ const hz = table.hz[key] ?? 0;
162
+ if (!(hz > 0)) {
163
+ out.push([0x7f, 0x7f, 0x7f]);
164
+ continue;
165
+ }
166
+ const value = 69 + 12 * Math.log2(hz / 440);
167
+ let semitone = Math.floor(value);
168
+ let fraction = Math.round((value - semitone) * 16384);
169
+ if (fraction >= 16384) {
170
+ semitone += 1;
171
+ fraction = 0;
172
+ }
173
+ if (semitone < 0) out.push([0, 0, 0]);
174
+ else if (semitone > 127 || (semitone === 127 && fraction > 16382))
175
+ out.push([0x7f, 0x7f, 0x7e]);
176
+ else out.push([semitone, (fraction >> 7) & 0x7f, fraction & 0x7f]);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** The SysEx and RPN events that tune a channel at tick 0. */
182
+ function tuningEvents(
183
+ keys: readonly number[][],
184
+ program: number,
185
+ channel: number,
186
+ ): TimedEvent[] {
187
+ const events: TimedEvent[] = [];
188
+ for (let first = 0; first < 128; first += 64) {
189
+ const data = [0x7f, 0x7f, 0x08, 0x02, program, 64];
190
+ for (let key = first; key < first + 64; key++)
191
+ data.push(key, ...keys[key]!);
192
+ data.push(0xf7);
193
+ events.push({
194
+ tick: 0,
195
+ order: -2,
196
+ bytes: [0xf0, ...varLen(data.length), ...data],
197
+ });
198
+ }
199
+ const cc = 0xb0 | channel;
200
+ events.push({
201
+ tick: 0,
202
+ order: -1,
203
+ bytes: [cc, 101, 0, 0, cc, 100, 3, 0, cc, 6, program, 0, cc, 101, 127],
204
+ });
205
+ events.push({ tick: 0, order: -1, bytes: [cc, 100, 127] });
206
+ return events;
207
+ }
208
+
209
+ /** Pitch-bend range written with RPN 0: ±24 semitones covers octave falls. */
210
+ export const MIDI_BEND_RANGE_SEMITONES = 24;
211
+
212
+ /**
213
+ * Pitch bend for notes with pitch expression (glide, bend, vibrato, cents).
214
+ * SMF pitch bend is per channel, so only notes that sound alone on their
215
+ * track get it (a mono line, a lead); notes under a chord stay at the key.
216
+ * The curve is sampled every 1/64 note and written when it changes; the
217
+ * bend returns to centre when the note ends. RPN 0 sets the range first.
218
+ */
219
+ function pitchBendEvents(
220
+ notes: readonly PerformedNote[],
221
+ channel: number,
222
+ timing: PerformanceTiming,
223
+ at: (tick: number) => number,
224
+ ): TimedEvent[] {
225
+ const sorted = [...notes].sort((a, b) => a.startTick - b.startTick);
226
+ const events: TimedEvent[] = [];
227
+ const step = Math.max(1, timing.ticksPerBeat / 16);
228
+ const range = MIDI_BEND_RANGE_SEMITONES * 100;
229
+ const seconds = (tick: number) =>
230
+ timing.secondsAt
231
+ ? timing.secondsAt(tick)
232
+ : (tick * 60) / (timing.tempoBpm * timing.ticksPerBeat);
233
+ const bend = (tick: number, order: number, cents: number) => {
234
+ const value = Math.min(
235
+ 16383,
236
+ Math.max(0, Math.round(8192 + (cents / range) * 8192)),
237
+ );
238
+ events.push({
239
+ tick,
240
+ order,
241
+ bytes: [0xe0 | channel, value & 0x7f, (value >>> 7) & 0x7f],
242
+ });
243
+ return value;
244
+ };
245
+ sorted.forEach((note, index) => {
246
+ const cents = note.performance?.cents;
247
+ if (!cents && !note.cents) return;
248
+ const stop = note.startTick + note.durationTicks;
249
+ const overlaps = sorted.some(
250
+ (other, j) =>
251
+ j !== index &&
252
+ other.startTick < stop &&
253
+ other.startTick + other.durationTicks > note.startTick,
254
+ );
255
+ if (overlaps) return;
256
+ const start = seconds(note.startTick);
257
+ const centsAtTick = (tick: number) =>
258
+ (note.cents ?? 0) + (cents ? cents(seconds(tick) - start) : 0);
259
+ // Before the note-on at the same tick.
260
+ let last = bend(at(note.startTick), 0.7, centsAtTick(note.startTick));
261
+ for (let tick = note.startTick + step; tick < stop; tick += step) {
262
+ const value = Math.min(
263
+ 16383,
264
+ Math.max(0, Math.round(8192 + (centsAtTick(tick) / range) * 8192)),
265
+ );
266
+ if (value === last || at(tick) === at(note.startTick)) continue;
267
+ last = bend(at(tick), 0.7, centsAtTick(tick));
268
+ }
269
+ if (last !== 8192) bend(at(stop), 0.2, 0);
270
+ });
271
+ if (events.length === 0) return events;
272
+ const cc = 0xb0 | channel;
273
+ events.push({
274
+ tick: 0,
275
+ order: -1,
276
+ bytes: [
277
+ cc,
278
+ 101,
279
+ 0,
280
+ 0,
281
+ cc,
282
+ 100,
283
+ 0,
284
+ 0,
285
+ cc,
286
+ 6,
287
+ MIDI_BEND_RANGE_SEMITONES,
288
+ 0,
289
+ cc,
290
+ 38,
291
+ 0,
292
+ 0,
293
+ cc,
294
+ 101,
295
+ 127,
296
+ 0,
297
+ cc,
298
+ 100,
299
+ 127,
300
+ ],
301
+ });
302
+ return events;
303
+ }
304
+
305
+ /** Encode a score as a format-1 Standard MIDI File. */
306
+ export function scoreToMidi(
307
+ score: TrackScore,
308
+ options: MidiOptions = {},
309
+ ): Uint8Array {
310
+ // Keep the division in range; scale ticks if a score ever exceeds it.
311
+ const scale =
312
+ score.ticksPerBeat > MAX_DIVISION ? MAX_DIVISION / score.ticksPerBeat : 1;
313
+ const division = Math.round(score.ticksPerBeat * scale);
314
+ const at = (tick: number) => Math.max(0, Math.round(tick * scale));
315
+ const end = loopTicksOf(score);
316
+
317
+ const conductor: TimedEvent[] = [];
318
+ for (const segment of meterSegments(score)) {
319
+ const clocks = Math.round(
320
+ (clickTicksOf(segment, score.ticksPerBeat) / score.ticksPerBeat) * 24,
321
+ );
322
+ conductor.push({
323
+ tick: at(segment.tick),
324
+ order: 0,
325
+ bytes: [
326
+ 0xff,
327
+ 0x58,
328
+ 4,
329
+ segment.beatsPerBar,
330
+ log2(segment.beatUnit),
331
+ Math.min(255, Math.max(1, clocks)),
332
+ 8,
333
+ ],
334
+ });
335
+ }
336
+ for (const event of midiTempoEvents(score, options)) {
337
+ const us = event.usPerQuarter;
338
+ conductor.push({
339
+ tick: at(event.tick),
340
+ order: 1,
341
+ bytes: [0xff, 0x51, 3, (us >>> 16) & 0xff, (us >>> 8) & 0xff, us & 0xff],
342
+ });
343
+ }
344
+ for (const section of score.sections ?? []) {
345
+ const tick = barStartTick(score, section.startBar);
346
+ if (tick >= end) continue;
347
+ const text = [...new TextEncoder().encode(section.name)].slice(0, 127);
348
+ conductor.push({
349
+ tick: at(tick),
350
+ order: 2,
351
+ bytes: [0xff, 0x06, ...varLen(text.length), ...text],
352
+ });
353
+ }
354
+ const chunks: number[][] = [trackChunk(conductor, "dawg", at(end))];
355
+ const programs = new Map<string, number>();
356
+ const placed = performedNotes(score);
357
+ const timing = performanceTimingFor(score);
358
+ let melodic = 0;
359
+ for (const track of score.tracks) {
360
+ const drum = isDrumInstrument(track.instrument) || track.kit !== undefined;
361
+ let channel: number;
362
+ if (drum) channel = 9;
363
+ else {
364
+ channel = melodic % 15;
365
+ if (channel >= 9) channel += 1;
366
+ melodic += 1;
367
+ }
368
+ const events: TimedEvent[] = [];
369
+ // The pedal is written as CC64, so notes are performed without it and
370
+ // keep the key's length.
371
+ const notes = performNotes(
372
+ track.pedal ? { ...track, pedal: undefined } : track,
373
+ placed.filter((note) => note.trackId === track.id),
374
+ timing,
375
+ );
376
+ const table =
377
+ score.tuning || track.tuning
378
+ ? resolveTuning(score.tuning, track.tuning, score.key)
379
+ : undefined;
380
+ if (table && !drum) {
381
+ const keys = mtsKeys(table);
382
+ let program = programs.get(keys.join(","));
383
+ if (program === undefined && programs.size < 128) {
384
+ program = programs.size;
385
+ programs.set(keys.join(","), program);
386
+ }
387
+ if (program !== undefined) {
388
+ events.push(...tuningEvents(keys, program, channel));
389
+ }
390
+ }
391
+ for (const event of track.pedal ?? []) {
392
+ if (event.tick > end) continue;
393
+ events.push({
394
+ tick: at(event.tick),
395
+ // After note-offs, before note-ons at the same tick.
396
+ order: 0.5,
397
+ bytes: [0xb0 | channel, 64, CC64[event.state]],
398
+ });
399
+ }
400
+ if (!drum) events.push(...pitchBendEvents(notes, channel, timing, at));
401
+ for (const note of notes) {
402
+ if (note.pitch < 0 || note.pitch > 127) continue;
403
+ const start = at(note.startTick);
404
+ const stop = Math.max(
405
+ start + 1,
406
+ at(Math.min(note.startTick + note.durationTicks, end)),
407
+ );
408
+ const velocity = Math.min(
409
+ 127,
410
+ Math.max(1, Math.round(note.velocity * 127)),
411
+ );
412
+ events.push({
413
+ tick: start,
414
+ order: 1,
415
+ bytes: [0x90 | channel, note.pitch, velocity],
416
+ });
417
+ // Note-offs sort before note-ons at the same tick so repeats retrigger.
418
+ events.push({
419
+ tick: stop,
420
+ order: 0,
421
+ bytes: [0x80 | channel, note.pitch, 0],
422
+ });
423
+ }
424
+ chunks.push(trackChunk(events, track.name, at(end)));
425
+ }
426
+
427
+ const header = [
428
+ 0x4d,
429
+ 0x54,
430
+ 0x68,
431
+ 0x64,
432
+ 0,
433
+ 0,
434
+ 0,
435
+ 6,
436
+ 0,
437
+ 1,
438
+ (chunks.length >>> 8) & 0xff,
439
+ chunks.length & 0xff,
440
+ (division >>> 8) & 0xff,
441
+ division & 0xff,
442
+ ];
443
+ const total = header.length + chunks.reduce((sum, c) => sum + c.length, 0);
444
+ const out = new Uint8Array(total);
445
+ out.set(header, 0);
446
+ let offset = header.length;
447
+ for (const chunk of chunks) {
448
+ out.set(chunk, offset);
449
+ offset += chunk.length;
450
+ }
451
+ return out;
452
+ }
package/core/rhythm.ts CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  type Note,
23
23
  type Track,
24
24
  } from "./score.ts";
25
+ import { NOTE_EXPRESSION_FIELDS } from "./expression.ts";
26
+ import { loopTicksOf } from "./tempo.ts";
25
27
 
26
28
  /** MIDI pitch a row's voice plays on `track`, or undefined when unknown. */
27
29
  export function rhythmVoicePitch(
@@ -70,7 +72,7 @@ export function expandTrackRow(
70
72
  `track ${track.id} has no voice "${row.voice}"`,
71
73
  "invalid-track",
72
74
  );
73
- const loopTicks = score.bars * score.beatsPerBar * score.ticksPerBeat;
75
+ const loopTicks = loopTicksOf(score);
74
76
  const prefix = `rh.${trackHash(track.id)}.${idVoice(row.voice)}.`;
75
77
  return expandRow(row, { ticksPerBeat: score.ticksPerBeat, loopTicks }).map(
76
78
  (hit, index) => ({
@@ -229,11 +231,14 @@ function sameNotes(
229
231
  ): boolean {
230
232
  if (actual.length !== expected.length) return false;
231
233
  const key = (note: Note) =>
232
- `${note.pitch}|${note.startTick}|${note.durationTicks}|${note.velocity}`;
234
+ `${note.pitch}|${note.startTick}|${note.durationTicks}|${note.velocity}|${note.cents ?? 0}`;
233
235
  const want = new Map<string, number>();
234
236
  for (const note of expected)
235
237
  want.set(key(note), (want.get(key(note)) ?? 0) + 1);
236
238
  for (const note of actual) {
239
+ // Expression is a hand edit: the generator cannot reproduce it.
240
+ if (NOTE_EXPRESSION_FIELDS.some((field) => note[field] !== undefined))
241
+ return false;
237
242
  const left = want.get(key(note));
238
243
  if (!left) return false;
239
244
  want.set(key(note), left - 1);