@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
package/core/key.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Key estimation from a 12-bin pitch-class histogram. Shared by session
3
+ * auto-naming (`src/session/naming.ts`) and audio analysis
4
+ * (`src/media/analyze.ts`), so both agree on what "a minor" means.
5
+ */
6
+ export const KEY_NOTE_NAMES = [
7
+ "c",
8
+ "c#",
9
+ "d",
10
+ "eb",
11
+ "e",
12
+ "f",
13
+ "f#",
14
+ "g",
15
+ "ab",
16
+ "a",
17
+ "bb",
18
+ "b",
19
+ ] as const;
20
+ const MAJOR = [0, 2, 4, 5, 7, 9, 11];
21
+ const MINOR = [0, 2, 3, 5, 7, 8, 10];
22
+
23
+ /** Best-fitting major or minor scale for a pitch-class histogram, or null. */
24
+ export function estimateKey(histogram: readonly number[]): string | null {
25
+ if (histogram.length < 12) return null;
26
+ const total = histogram.reduce((sum, value) => sum + value, 0);
27
+ if (!(total > 0)) return null;
28
+ let best: { key: string; score: number } | undefined;
29
+ for (let tonic = 0; tonic < 12; tonic += 1) {
30
+ for (const [mode, scale] of [
31
+ ["major", MAJOR],
32
+ ["minor", MINOR],
33
+ ] as const) {
34
+ let fit = 0;
35
+ for (const step of scale) fit += histogram[(tonic + step) % 12]!;
36
+ // Tonic and fifth weigh extra so relative keys separate.
37
+ fit += histogram[tonic]! * 0.75 + histogram[(tonic + 7) % 12]! * 0.25;
38
+ if (!best || fit > best.score + 1e-9)
39
+ best = { key: `${KEY_NOTE_NAMES[tonic]} ${mode}`, score: fit };
40
+ }
41
+ }
42
+ return best!.key;
43
+ }
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/pitch.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pitch-name helpers shared by the command parser, the SDK and the printer.
3
+ *
4
+ * Names are scientific pitch notation: a letter `A`–`G`, an optional `#` or
5
+ * `b`, and an octave from -1 to 9 (`C4` = 60, `A4` = 69). Parsing is
6
+ * case-insensitive; printing always uses sharps so a name round-trips to the
7
+ * same MIDI number.
8
+ */
9
+
10
+ const SEMITONES: Readonly<Record<string, number>> = Object.freeze({
11
+ c: 0,
12
+ d: 2,
13
+ e: 4,
14
+ f: 5,
15
+ g: 7,
16
+ a: 9,
17
+ b: 11,
18
+ });
19
+
20
+ const NAMES = Object.freeze([
21
+ "C",
22
+ "C#",
23
+ "D",
24
+ "D#",
25
+ "E",
26
+ "F",
27
+ "F#",
28
+ "G",
29
+ "G#",
30
+ "A",
31
+ "A#",
32
+ "B",
33
+ ] as const);
34
+
35
+ /** MIDI number for a pitch name, or NaN when the name is malformed or out of range. */
36
+ export function pitchToMidi(value: string): number {
37
+ if (typeof value !== "string") return Number.NaN;
38
+ const match = value.trim().match(/^([a-gA-G])([#b]?)(-?\d{1,2})$/);
39
+ if (!match) return Number.NaN;
40
+ const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
41
+ const midi =
42
+ (Number(match[3]) + 1) * 12 +
43
+ (SEMITONES[match[1]!.toLowerCase()] ?? 0) +
44
+ accidental;
45
+ return midi >= 0 && midi <= 127 ? midi : Number.NaN;
46
+ }
47
+
48
+ /** Pitch name with sharps for a MIDI number 0..127 (`60` → `C4`). */
49
+ export function midiToPitch(midi: number): string {
50
+ if (!Number.isInteger(midi) || midi < 0 || midi > 127)
51
+ throw new RangeError(`MIDI pitch out of range: ${String(midi)}`);
52
+ return `${NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
53
+ }
54
+
55
+ /** True when `value` is a MIDI integer 0..127 or a parseable pitch name. */
56
+ export function isPitch(value: unknown): value is number | string {
57
+ if (typeof value === "number")
58
+ return Number.isInteger(value) && value >= 0 && value <= 127;
59
+ return typeof value === "string" && Number.isFinite(pitchToMidi(value));
60
+ }