@hraness/dawg 0.3.0 → 0.4.1

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 (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/core/kits.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Synthesized drum kits: parameter sets for the built-in drum synth
3
+ * (`src/audio/kits.ts`). A kit is chosen per track (`kit: "syn808"` on an
4
+ * `instrument: "kit"` track); a track without `kit` plays the original
5
+ * voices, byte-for-byte unchanged.
6
+ *
7
+ * Kits are plain data so the score can validate names without importing
8
+ * the renderer, and every kit renders deterministically (noise is seeded
9
+ * per note, exactly as in the default kit).
10
+ *
11
+ * Sample kits (Roland TR banks and friends from Strudel-format packs) live
12
+ * in `src/audio/packs.ts` (`DEFAULT_KITS`, `/kit 909`); `src/audio/kits.ts`
13
+ * lists both kinds in one catalog.
14
+ */
15
+ /** Pitched body with an exponential pitch drop (kick, tom). */
16
+ export type DrumBodyParams = Readonly<{
17
+ /** Resting frequency, Hz. */
18
+ base: number;
19
+ /** Extra Hz at the attack, decaying at `sweepRate` per second. */
20
+ sweep: number;
21
+ sweepRate: number;
22
+ /** Amplitude decay rate per second (higher is shorter). */
23
+ decay: number;
24
+ /** Noise click level at the attack and its decay rate. */
25
+ click: number;
26
+ clickDecay: number;
27
+ /** Level of the body. */
28
+ level: number;
29
+ }>;
30
+
31
+ export type SnareParams = Readonly<{
32
+ /** Drum-head tone, Hz, with its level and decay rate. */
33
+ tone: number;
34
+ toneLevel: number;
35
+ toneDecay: number;
36
+ /** Snare-wire noise level and decay rate. */
37
+ noise: number;
38
+ noiseDecay: number;
39
+ /** 0 dull … 1 bright wires (white vs high-passed noise). */
40
+ snap: number;
41
+ }>;
42
+
43
+ export type ClapParams = Readonly<{
44
+ /** Seconds between the three bursts. */
45
+ gap: number;
46
+ /** Decay rate of the diffuse tail. */
47
+ tail: number;
48
+ /** Band-pass smoothing 0..1 (higher is brighter). */
49
+ band: number;
50
+ level: number;
51
+ }>;
52
+
53
+ export type HatParams = Readonly<{
54
+ level: number;
55
+ decay: number;
56
+ /** 0 dark … 1 bright noise. */
57
+ tone: number;
58
+ /** 0 noise … 1 six detuned square oscillators (808-style metal). */
59
+ metal: number;
60
+ }>;
61
+
62
+ export type RimParams = Readonly<{
63
+ high: number;
64
+ low: number;
65
+ decay: number;
66
+ level: number;
67
+ }>;
68
+
69
+ export type SynthKit = Readonly<{
70
+ name: string;
71
+ label: string;
72
+ /** One line for pickers. */
73
+ description: string;
74
+ kick: DrumBodyParams;
75
+ tom: DrumBodyParams;
76
+ snare: SnareParams;
77
+ clap: ClapParams;
78
+ hat: HatParams;
79
+ openhat: HatParams;
80
+ rim: RimParams;
81
+ /** Longest one-shot, seconds (808 booms ring longer). */
82
+ seconds: number;
83
+ /** 0..1 tanh saturation. */
84
+ drive: number;
85
+ /** Bit depth for crushing; 0 is off. */
86
+ bits: number;
87
+ /** Sample-and-hold factor (1 = off) for a dusty, aliased top end. */
88
+ hold: number;
89
+ /** Output gain. */
90
+ gain: number;
91
+ }>;
92
+
93
+ /** The original voices as parameters (the default kit is not routed through these). */
94
+ type KitParams = Omit<SynthKit, "name" | "label" | "description">;
95
+
96
+ const BASE: KitParams = Object.freeze({
97
+ kick: {
98
+ base: 45,
99
+ sweep: 105,
100
+ sweepRate: 28,
101
+ decay: 7.5,
102
+ click: 0.12,
103
+ clickDecay: 300,
104
+ level: 1,
105
+ },
106
+ tom: {
107
+ base: 105,
108
+ sweep: 95,
109
+ sweepRate: 18,
110
+ decay: 9,
111
+ click: 0,
112
+ clickDecay: 300,
113
+ level: 1,
114
+ },
115
+ snare: {
116
+ tone: 185,
117
+ toneLevel: 0.45,
118
+ toneDecay: 22,
119
+ noise: 0.7,
120
+ noiseDecay: 16,
121
+ snap: 0,
122
+ },
123
+ clap: { gap: 0.01, tail: 18, band: 0.35, level: 1 },
124
+ hat: { level: 0.42, decay: 60, tone: 1, metal: 0 },
125
+ openhat: { level: 0.36, decay: 9, tone: 1, metal: 0 },
126
+ rim: { high: 1_700, low: 820, decay: 90, level: 1 },
127
+ seconds: 0.6,
128
+ drive: 0,
129
+ bits: 0,
130
+ hold: 1,
131
+ gain: 1,
132
+ });
133
+
134
+ type KitOverrides = Readonly<
135
+ Partial<{
136
+ [K in keyof KitParams]: KitParams[K] extends object
137
+ ? Partial<KitParams[K]>
138
+ : KitParams[K];
139
+ }>
140
+ >;
141
+
142
+ function kit(
143
+ name: string,
144
+ label: string,
145
+ description: string,
146
+ overrides: KitOverrides,
147
+ ): SynthKit {
148
+ const out: Record<string, unknown> = { name, label, description };
149
+ for (const [key, value] of Object.entries(BASE)) {
150
+ const override = (overrides as Record<string, unknown>)[key];
151
+ out[key] =
152
+ typeof value === "object"
153
+ ? Object.freeze({ ...value, ...(override as object | undefined) })
154
+ : (override ?? value);
155
+ }
156
+ return Object.freeze(out) as SynthKit;
157
+ }
158
+
159
+ /** Synthesized kits in picker order. */
160
+ export const SYNTH_KITS: readonly SynthKit[] = Object.freeze([
161
+ kit("syn808", "808 (synth)", "long sub boom, snappy snare, metallic hats", {
162
+ kick: {
163
+ base: 49,
164
+ sweep: 70,
165
+ sweepRate: 20,
166
+ decay: 2.6,
167
+ click: 0.03,
168
+ clickDecay: 400,
169
+ },
170
+ tom: { base: 95, sweep: 40, sweepRate: 14, decay: 5 },
171
+ snare: {
172
+ tone: 238,
173
+ toneLevel: 0.5,
174
+ toneDecay: 18,
175
+ noise: 0.55,
176
+ noiseDecay: 20,
177
+ snap: 0.8,
178
+ },
179
+ clap: { gap: 0.011, tail: 14 },
180
+ hat: { level: 0.36, decay: 70, metal: 1 },
181
+ openhat: { level: 0.3, decay: 7, metal: 1 },
182
+ rim: { high: 1_870, low: 470, decay: 110 },
183
+ seconds: 1.4,
184
+ }),
185
+ kit("syn909", "909 (synth)", "punchy clicky kick, bright noisy snare", {
186
+ kick: {
187
+ base: 52,
188
+ sweep: 190,
189
+ sweepRate: 38,
190
+ decay: 6,
191
+ click: 0.35,
192
+ clickDecay: 500,
193
+ },
194
+ snare: {
195
+ tone: 205,
196
+ toneLevel: 0.4,
197
+ toneDecay: 26,
198
+ noise: 0.9,
199
+ noiseDecay: 12,
200
+ snap: 1,
201
+ },
202
+ clap: { gap: 0.009, tail: 12, band: 0.5 },
203
+ hat: { level: 0.4, decay: 55, tone: 1, metal: 0.3 },
204
+ openhat: { level: 0.34, decay: 6, metal: 0.3 },
205
+ drive: 0.35,
206
+ }),
207
+ kit("acoustic", "Acoustic-ish", "beater kick, wire snare, darker cymbals", {
208
+ kick: {
209
+ base: 62,
210
+ sweep: 45,
211
+ sweepRate: 30,
212
+ decay: 11,
213
+ click: 0.3,
214
+ clickDecay: 180,
215
+ },
216
+ tom: { base: 120, sweep: 50, sweepRate: 10, decay: 7, click: 0.1 },
217
+ snare: {
218
+ tone: 175,
219
+ toneLevel: 0.35,
220
+ toneDecay: 18,
221
+ noise: 0.85,
222
+ noiseDecay: 9,
223
+ snap: 0.3,
224
+ },
225
+ clap: { tail: 22, band: 0.25 },
226
+ hat: { level: 0.4, decay: 45, tone: 0.55, metal: 0.5 },
227
+ openhat: { level: 0.34, decay: 4.5, tone: 0.55, metal: 0.5 },
228
+ rim: { high: 1_300, low: 640, decay: 70 },
229
+ seconds: 0.9,
230
+ }),
231
+ kit("lofi", "Lo-fi dusty", "soft round kick, crushed and dark", {
232
+ kick: { base: 50, sweep: 70, sweepRate: 22, decay: 8, click: 0.05 },
233
+ snare: { toneLevel: 0.5, noise: 0.6, noiseDecay: 14, snap: 0 },
234
+ hat: { level: 0.38, decay: 50, tone: 0.3 },
235
+ openhat: { level: 0.3, decay: 8, tone: 0.3 },
236
+ drive: 0.25,
237
+ bits: 8,
238
+ hold: 3,
239
+ gain: 0.95,
240
+ }),
241
+ kit(
242
+ "electro",
243
+ "Electro minimal",
244
+ "tight short kick, clicky rim, ticking hats",
245
+ {
246
+ kick: {
247
+ base: 56,
248
+ sweep: 240,
249
+ sweepRate: 60,
250
+ decay: 13,
251
+ click: 0.2,
252
+ clickDecay: 700,
253
+ },
254
+ tom: { base: 140, sweep: 160, sweepRate: 40, decay: 14 },
255
+ snare: {
256
+ tone: 310,
257
+ toneLevel: 0.55,
258
+ toneDecay: 35,
259
+ noise: 0.5,
260
+ noiseDecay: 28,
261
+ snap: 1,
262
+ },
263
+ clap: { gap: 0.008, tail: 26, band: 0.6 },
264
+ hat: { level: 0.38, decay: 120, metal: 0.6 },
265
+ openhat: { level: 0.32, decay: 14, metal: 0.6 },
266
+ rim: { high: 2_400, low: 1_100, decay: 140 },
267
+ seconds: 0.5,
268
+ },
269
+ ),
270
+ kit("trap", "Trap", "distorted long 808, crisp hats, high snare", {
271
+ kick: {
272
+ base: 43,
273
+ sweep: 90,
274
+ sweepRate: 16,
275
+ decay: 1.6,
276
+ click: 0.08,
277
+ clickDecay: 400,
278
+ level: 0.9,
279
+ },
280
+ snare: {
281
+ tone: 265,
282
+ toneLevel: 0.45,
283
+ toneDecay: 20,
284
+ noise: 0.8,
285
+ noiseDecay: 15,
286
+ snap: 1,
287
+ },
288
+ clap: { gap: 0.012, tail: 16, band: 0.55 },
289
+ hat: { level: 0.4, decay: 95, tone: 1, metal: 0.4 },
290
+ openhat: { level: 0.32, decay: 8, metal: 0.4 },
291
+ seconds: 1.6,
292
+ drive: 0.55,
293
+ }),
294
+ ]);
295
+
296
+ const BY_NAME = new Map(SYNTH_KITS.map((entry) => [entry.name, entry]));
297
+
298
+ /** Accepted spellings for kit names (`808` resolves to the sample bank instead). */
299
+ const KIT_ALIASES: Readonly<Record<string, string>> = Object.freeze({
300
+ synth808: "syn808",
301
+ "synth-808": "syn808",
302
+ "syn-808": "syn808",
303
+ synth909: "syn909",
304
+ "synth-909": "syn909",
305
+ "syn-909": "syn909",
306
+ "lo-fi": "lofi",
307
+ dusty: "lofi",
308
+ minimal: "electro",
309
+ });
310
+
311
+ /** The synth kit named `name` (case-insensitive, with aliases), if any. */
312
+ export function synthKit(name: string | undefined): SynthKit | undefined {
313
+ if (typeof name !== "string") return undefined;
314
+ const key = name.trim().toLowerCase();
315
+ return BY_NAME.get(KIT_ALIASES[key] ?? key);
316
+ }
317
+
318
+ export const SYNTH_KIT_NAMES: readonly string[] = Object.freeze(
319
+ SYNTH_KITS.map((entry) => entry.name),
320
+ );
package/core/params.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Typed parameter specs shared by the effects chain (core/fx.ts) and the
3
+ * synth voice (core/synth.ts): one table per unit drives validation,
4
+ * printing, the menu, prompt commands and agent tools.
5
+ */
6
+
7
+ export type NumberParam = Readonly<{
8
+ kind: "number";
9
+ min: number;
10
+ max: number;
11
+ default: number;
12
+ /** Menu nudge step; `"log"` nudges by a sixth of an octave. */
13
+ step: number | "log";
14
+ unit?: string;
15
+ /** Round stored values to an integer. */
16
+ integer?: boolean;
17
+ /** Not filled with the default when absent (absent keeps legacy behaviour). */
18
+ optional?: boolean;
19
+ /** A lane `<effect>-<param>` exists for this parameter. */
20
+ automate?: boolean;
21
+ doc: string;
22
+ /** Strudel names (and aliases) that mean this parameter. */
23
+ strudel?: readonly string[];
24
+ }>;
25
+
26
+ export type EnumParam = Readonly<{
27
+ kind: "enum";
28
+ values: readonly string[];
29
+ default: string;
30
+ optional?: boolean;
31
+ doc: string;
32
+ strudel?: readonly string[];
33
+ }>;
34
+
35
+ export type BooleanParam = Readonly<{
36
+ kind: "boolean";
37
+ default: boolean;
38
+ optional?: boolean;
39
+ doc: string;
40
+ strudel?: readonly string[];
41
+ }>;
42
+
43
+ export type ParamSpec = NumberParam | EnumParam | BooleanParam;
44
+
45
+ /** A validated parameter record. */
46
+ export type FxValues = Readonly<Record<string, number | string | boolean>>;
47
+
48
+ export class FxValidationError extends Error {
49
+ constructor(message: string) {
50
+ super(message);
51
+ this.name = "FxValidationError";
52
+ }
53
+ }
54
+
55
+ /** Validates one parameter value against its spec. */
56
+ export function normalizeParam(
57
+ spec: ParamSpec,
58
+ value: unknown,
59
+ label: string,
60
+ ): number | string | boolean {
61
+ if (spec.kind === "enum") {
62
+ if (typeof value !== "string" || !spec.values.includes(value))
63
+ throw new FxValidationError(
64
+ `${label} must be one of ${spec.values.join(", ")}`,
65
+ );
66
+ return value;
67
+ }
68
+ if (spec.kind === "boolean") {
69
+ if (typeof value !== "boolean")
70
+ throw new FxValidationError(`${label} must be true or false`);
71
+ return value;
72
+ }
73
+ if (typeof value !== "number" || !Number.isFinite(value))
74
+ throw new FxValidationError(`${label} must be a finite number`);
75
+ if (value < spec.min || value > spec.max)
76
+ throw new FxValidationError(
77
+ `${label} must be between ${spec.min} and ${spec.max}`,
78
+ );
79
+ return spec.integer ? Math.round(value) : value;
80
+ }
81
+
82
+ /**
83
+ * Validates a parameter record against `params`, filling defaults for
84
+ * absent keys. Unknown keys are rejected so typos never pass silently.
85
+ */
86
+ export function normalizeParams(
87
+ params: Readonly<Record<string, ParamSpec>>,
88
+ input: unknown,
89
+ label: string,
90
+ fill = true,
91
+ ): FxValues {
92
+ if (!isRecord(input))
93
+ throw new FxValidationError(`${label} must be an object`);
94
+ for (const key of Object.keys(input))
95
+ if (!Object.prototype.hasOwnProperty.call(params, key))
96
+ throw new FxValidationError(`${label} has no parameter "${key}"`);
97
+ const out: Record<string, number | string | boolean> = {};
98
+ for (const [key, spec] of Object.entries(params)) {
99
+ const value = input[key];
100
+ if (value === undefined) {
101
+ if (fill && !spec.optional) out[key] = spec.default;
102
+ continue;
103
+ }
104
+ out[key] = normalizeParam(spec, value, `${label} ${key}`);
105
+ }
106
+ return Object.freeze(out);
107
+ }
108
+
109
+ export function isRecord(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ }
package/core/rhythm.ts ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Rhythm rows on a score: the glue between `core/euclid.ts` generators and
3
+ * the stored notes.
4
+ *
5
+ * A track's `rhythm` rows each own one voice lane (a drum voice, a one-shot
6
+ * sampler voice, or a pitch). `withTrackRhythm` replaces the rows and
7
+ * regenerates exactly those lanes' notes, so an edit is one next score and
8
+ * therefore one revision and one undo step. Other notes on the track, and
9
+ * every other track, are untouched. Generated notes carry deterministic ids
10
+ * (`rh.<track hash>.<voice>.<n>`), so the same rows always produce the same
11
+ * score and `diffScores` sees only real changes.
12
+ */
13
+ import { isDrumInstrument, parseDrumVoice, drumVoicePitch } from "./drums.ts";
14
+ import { expandRow, rowSummary, type RhythmRow } from "./euclid.ts";
15
+ import { pitchToMidi } from "./pitch.ts";
16
+ import {
17
+ SCORE_LIMITS,
18
+ ScoreValidationError,
19
+ TrackScore,
20
+ isSamplerInstrument,
21
+ samplerVoiceSlots,
22
+ type Note,
23
+ type Track,
24
+ } from "./score.ts";
25
+
26
+ /** MIDI pitch a row's voice plays on `track`, or undefined when unknown. */
27
+ export function rhythmVoicePitch(
28
+ track: Track,
29
+ voice: string,
30
+ ): number | undefined {
31
+ const name = voice.trim();
32
+ if (isSamplerInstrument(track.instrument) && track.sampler) {
33
+ const slot = samplerVoiceSlots(track.sampler).get(name);
34
+ if (slot !== undefined) return slot;
35
+ }
36
+ if (isDrumInstrument(track.instrument)) {
37
+ const drum = parseDrumVoice(name);
38
+ if (drum) return drumVoicePitch(drum);
39
+ }
40
+ if (/^\d{1,3}$/.test(name)) {
41
+ const midi = Number(name);
42
+ return midi <= 127 ? midi : undefined;
43
+ }
44
+ const midi = pitchToMidi(name);
45
+ return Number.isNaN(midi) ? undefined : midi;
46
+ }
47
+
48
+ function trackHash(trackId: string): string {
49
+ let hash = 0x811c9dc5;
50
+ for (let index = 0; index < trackId.length; index += 1) {
51
+ hash ^= trackId.charCodeAt(index);
52
+ hash = Math.imul(hash, 0x01000193);
53
+ }
54
+ return (hash >>> 0).toString(16).padStart(8, "0");
55
+ }
56
+
57
+ function idVoice(voice: string): string {
58
+ return voice.replace(/[^A-Za-z0-9_#-]/g, "_").slice(0, 32);
59
+ }
60
+
61
+ /** The notes `row` generates on `track` over the score's loop. */
62
+ export function expandTrackRow(
63
+ score: TrackScore,
64
+ track: Track,
65
+ row: RhythmRow,
66
+ ): Note[] {
67
+ const pitch = rhythmVoicePitch(track, row.voice);
68
+ if (pitch === undefined)
69
+ throw new ScoreValidationError(
70
+ `track ${track.id} has no voice "${row.voice}"`,
71
+ "invalid-track",
72
+ );
73
+ const loopTicks = score.bars * score.beatsPerBar * score.ticksPerBeat;
74
+ const prefix = `rh.${trackHash(track.id)}.${idVoice(row.voice)}.`;
75
+ return expandRow(row, { ticksPerBeat: score.ticksPerBeat, loopTicks }).map(
76
+ (hit, index) => ({
77
+ id: `${prefix}${index}`,
78
+ trackId: track.id,
79
+ startTick: hit.startTick,
80
+ durationTicks: Math.min(hit.durationTicks, SCORE_LIMITS.maxTick),
81
+ pitch,
82
+ velocity: hit.velocity,
83
+ }),
84
+ );
85
+ }
86
+
87
+ /** Pitches the rows own on `track` (unresolvable voices are skipped). */
88
+ function ownedPitches(track: Track, rows: readonly RhythmRow[]): Set<number> {
89
+ const pitches = new Set<number>();
90
+ for (const row of rows) {
91
+ const pitch = rhythmVoicePitch(track, row.voice);
92
+ if (pitch !== undefined) pitches.add(pitch);
93
+ }
94
+ return pitches;
95
+ }
96
+
97
+ /**
98
+ * The score with `trackId`'s rows replaced by `rows` (empty removes them)
99
+ * and the lanes of the old and new rows regenerated. Throws
100
+ * `ScoreValidationError` for an unknown track or voice, or past the note
101
+ * limit.
102
+ */
103
+ export function withTrackRhythm(
104
+ score: TrackScore,
105
+ trackId: string,
106
+ rows: readonly RhythmRow[],
107
+ ): TrackScore {
108
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
109
+ if (!track)
110
+ throw new ScoreValidationError(`no track ${trackId}`, "invalid-track");
111
+ const nextTrack: Track = { ...track };
112
+ const tracks = score.tracks.map((candidate) =>
113
+ candidate.id === trackId
114
+ ? { ...candidate, rhythm: rows.length > 0 ? rows : null }
115
+ : candidate,
116
+ );
117
+ const cleared = ownedPitches(track, [...(track.rhythm ?? []), ...rows]);
118
+ const generated = rows.flatMap((row) =>
119
+ expandTrackRow(score, nextTrack, row),
120
+ );
121
+ const notes = [
122
+ ...score.notes.filter(
123
+ (note) => note.trackId !== trackId || !cleared.has(note.pitch),
124
+ ),
125
+ ...generated,
126
+ ];
127
+ if (notes.length > SCORE_LIMITS.maxNotes)
128
+ throw new ScoreValidationError(
129
+ `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
130
+ "invalid-score",
131
+ );
132
+ return new TrackScore({ ...score.toJSON(), tracks, notes });
133
+ }
134
+
135
+ /** Adds or replaces the row for `row.voice` on the track. */
136
+ export function setRhythmRow(
137
+ score: TrackScore,
138
+ trackId: string,
139
+ row: RhythmRow,
140
+ ): TrackScore {
141
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
142
+ const current = track?.rhythm ?? [];
143
+ const index = current.findIndex((existing) => existing.voice === row.voice);
144
+ const rows =
145
+ index < 0
146
+ ? [...current, row]
147
+ : current.map((existing, at) => (at === index ? row : existing));
148
+ return withTrackRhythm(score, trackId, rows);
149
+ }
150
+
151
+ /**
152
+ * Drops the row for `voice`. With `keepNotes` its generated notes stay as
153
+ * plain notes (the row is "frozen"); otherwise the lane is cleared.
154
+ */
155
+ export function removeRhythmRow(
156
+ score: TrackScore,
157
+ trackId: string,
158
+ voice: string,
159
+ keepNotes = false,
160
+ ): TrackScore {
161
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
162
+ const current = track?.rhythm ?? [];
163
+ const rows = current.filter((row) => row.voice !== voice);
164
+ if (rows.length === current.length) return score;
165
+ if (!keepNotes) return withTrackRhythm(score, trackId, rows);
166
+ return new TrackScore({
167
+ ...score.toJSON(),
168
+ tracks: score.tracks.map((candidate) =>
169
+ candidate.id === trackId
170
+ ? { ...candidate, rhythm: rows.length > 0 ? rows : null }
171
+ : candidate,
172
+ ),
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Every track's rows regenerated for the current loop length (after a
178
+ * `bars` change, or for a score fresh from `song.ts`). Returns `score`
179
+ * itself when nothing changes.
180
+ */
181
+ export function refreshRhythm(score: TrackScore): TrackScore {
182
+ let next = score;
183
+ for (const track of score.tracks)
184
+ if (track.rhythm && !rhythmInSync(next, track.id))
185
+ next = withTrackRhythm(next, track.id, track.rhythm);
186
+ return next;
187
+ }
188
+
189
+ /** True when the track's owned lanes hold exactly what its rows generate. */
190
+ export function rhythmInSync(score: TrackScore, trackId: string): boolean {
191
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
192
+ if (!track?.rhythm) return true;
193
+ let expected: Note[];
194
+ try {
195
+ expected = track.rhythm.flatMap((row) => expandTrackRow(score, track, row));
196
+ } catch {
197
+ return false;
198
+ }
199
+ const owned = ownedPitches(track, track.rhythm);
200
+ const actual = score.notes.filter(
201
+ (note) => note.trackId === trackId && owned.has(note.pitch),
202
+ );
203
+ return sameNotes(expected, actual);
204
+ }
205
+
206
+ /** True when `row`'s lane on the track holds exactly what the row generates. */
207
+ export function rowInSync(
208
+ score: TrackScore,
209
+ track: Track,
210
+ row: RhythmRow,
211
+ ): boolean {
212
+ const pitch = rhythmVoicePitch(track, row.voice);
213
+ if (pitch === undefined) return false;
214
+ let expected: Note[];
215
+ try {
216
+ expected = expandTrackRow(score, track, row);
217
+ } catch {
218
+ return false;
219
+ }
220
+ const actual = score.notes.filter(
221
+ (note) => note.trackId === track.id && note.pitch === pitch,
222
+ );
223
+ return sameNotes(expected, actual);
224
+ }
225
+
226
+ function sameNotes(
227
+ expected: readonly Note[],
228
+ actual: readonly Note[],
229
+ ): boolean {
230
+ if (actual.length !== expected.length) return false;
231
+ const key = (note: Note) =>
232
+ `${note.pitch}|${note.startTick}|${note.durationTicks}|${note.velocity}`;
233
+ const want = new Map<string, number>();
234
+ for (const note of expected)
235
+ want.set(key(note), (want.get(key(note)) ?? 0) + 1);
236
+ for (const note of actual) {
237
+ const left = want.get(key(note));
238
+ if (!left) return false;
239
+ want.set(key(note), left - 1);
240
+ }
241
+ return true;
242
+ }
243
+
244
+ /**
245
+ * Keeps rows and their lanes consistent after an arbitrary edit from
246
+ * `previous` to `next`: when the loop grid changed (bars, meter, ticks per
247
+ * beat) every row regenerates; otherwise a row whose lane was hand-edited
248
+ * is frozen (dropped, its notes kept as plain notes). Returns `next` itself
249
+ * when nothing needs to change.
250
+ */
251
+ export function reconcileRhythm(
252
+ previous: TrackScore,
253
+ next: TrackScore,
254
+ ): TrackScore {
255
+ const regridded =
256
+ previous.bars !== next.bars ||
257
+ previous.beatsPerBar !== next.beatsPerBar ||
258
+ previous.ticksPerBeat !== next.ticksPerBeat;
259
+ let out = next;
260
+ for (const track of next.tracks) {
261
+ if (!track.rhythm) continue;
262
+ if (regridded) {
263
+ if (!rhythmInSync(out, track.id))
264
+ out = withTrackRhythm(out, track.id, track.rhythm);
265
+ continue;
266
+ }
267
+ const current = out.tracks.find((candidate) => candidate.id === track.id)!;
268
+ const kept = track.rhythm.filter((row) => rowInSync(out, current, row));
269
+ if (kept.length !== track.rhythm.length)
270
+ out = new TrackScore({
271
+ ...out.toJSON(),
272
+ tracks: out.tracks.map((candidate) =>
273
+ candidate.id === track.id
274
+ ? { ...candidate, rhythm: kept.length > 0 ? kept : null }
275
+ : candidate,
276
+ ),
277
+ });
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /** `kick E(4,16) · hat E(7,16,r2)` for a status line. */
283
+ export function rhythmSummary(track: Track): string {
284
+ return (track.rhythm ?? [])
285
+ .map((row) => `${row.voice} ${rowSummary(row)}`)
286
+ .join(" · ");
287
+ }