@hraness/dawg 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/core/loop.ts CHANGED
@@ -1,11 +1,16 @@
1
+ import type { SongTime } from "./tempo.ts";
1
2
  import {
2
3
  SCORE_VERSION,
3
4
  TrackScore,
4
5
  ScoreValidationError,
5
6
  scoreFromJSON,
7
+ type FormEntry,
6
8
  type Note,
9
+ type Section,
7
10
  type Track,
8
11
  } from "./score.ts";
12
+ import type { SongMaster } from "./master.ts";
13
+ import type { Tuning } from "./tuning.ts";
9
14
 
10
15
  export const LOOP_FORMAT = "track.loop/v1" as const;
11
16
 
@@ -17,8 +22,18 @@ export type TrackLoopV1 = Readonly<{
17
22
  bars: number;
18
23
  ticksPerBeat: number;
19
24
  key: string | null;
25
+ /** Tempo map, meter changes and fermatas (0.5); absent: constant time. */
26
+ time?: SongTime;
27
+ /** Song tuning (0.5); absent: 12-TET at A4 = 440 Hz. */
28
+ tuning?: Tuning;
20
29
  tracks: readonly Track[];
21
30
  notes: readonly Note[];
31
+ /** Song master (dawg 0.5); absent means none. */
32
+ master?: SongMaster;
33
+ /** Song sections (0.5); omitted when the song has none. */
34
+ sections?: readonly Section[];
35
+ /** Song form (0.5); omitted when empty. */
36
+ form?: readonly FormEntry[];
22
37
  }>;
23
38
 
24
39
  /** Return the stable object form used by files and IPC messages. */
@@ -33,8 +48,16 @@ export function encodeLoopDocument(score: TrackScore): TrackLoopV1 {
33
48
  bars: score.bars,
34
49
  ticksPerBeat: score.ticksPerBeat,
35
50
  key: score.key,
51
+ ...(score.time ? { time: score.time } : {}),
52
+ ...(score.tuning ? { tuning: score.tuning } : {}),
36
53
  tracks: score.tracks,
37
54
  notes: score.notes,
55
+ ...(score.master ? { master: score.master } : {}),
56
+ ...(score.sections.length > 0 ? { sections: score.sections } : {}),
57
+ ...(score.form.length > 0 ? { form: score.form } : {}),
58
+ ...(score.loopSection === undefined
59
+ ? {}
60
+ : { loopSection: score.loopSection }),
38
61
  });
39
62
  }
40
63
 
package/core/master.ts ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * The song master: optional mastering units applied to the whole mix after
3
+ * the track and orbit-bus sum (DSP in src/audio/master.ts), and a loudness
4
+ * target in LUFS (ITU-R BS.1770-4) that renders and exports normalize to.
5
+ * One table per unit drives validation, the SDK printer, the menu, the
6
+ * `master` prompt command and the agent tools, like core/fx.ts.
7
+ *
8
+ * No master, or a master with nothing on, is a bypass: renders stay
9
+ * byte-identical to dawg 0.4.
10
+ */
11
+ import {
12
+ FxValidationError,
13
+ isRecord,
14
+ normalizeParams,
15
+ type FxValues,
16
+ type NumberParam,
17
+ type ParamSpec,
18
+ } from "./params.ts";
19
+
20
+ /** Units in processing order. */
21
+ export const MASTER_UNITS = Object.freeze([
22
+ "eq",
23
+ "glue",
24
+ "tape",
25
+ "width",
26
+ "limiter",
27
+ ] as const);
28
+ export type MasterUnit = (typeof MASTER_UNITS)[number];
29
+
30
+ export type MasterUnitSpec = Readonly<{
31
+ label: string;
32
+ doc: string;
33
+ /** Parameters in display order; `simple` are the menu's top level. */
34
+ params: Readonly<Record<string, ParamSpec>>;
35
+ simple: readonly string[];
36
+ }>;
37
+
38
+ /** The stored master. Every field is optional; absent means off. */
39
+ export type SongMaster = Readonly<{
40
+ eq?: FxValues;
41
+ glue?: FxValues;
42
+ tape?: FxValues;
43
+ width?: FxValues;
44
+ limiter?: FxValues;
45
+ /** Integrated loudness target in LUFS; renders normalize to it. */
46
+ target?: number;
47
+ }>;
48
+
49
+ export const MASTER_LIMITS = Object.freeze({
50
+ minTarget: -40,
51
+ maxTarget: -3,
52
+ /** Largest gain the target may add or remove, dB. */
53
+ maxTargetGain: 48,
54
+ /** Ceiling for a target without the limiter, dBTP. */
55
+ safeCeiling: -1,
56
+ });
57
+
58
+ const db = (
59
+ min: number,
60
+ max: number,
61
+ fallback: number,
62
+ doc: string,
63
+ step = 0.5,
64
+ ): NumberParam => ({
65
+ kind: "number",
66
+ min,
67
+ max,
68
+ default: fallback,
69
+ step,
70
+ unit: "dB",
71
+ doc,
72
+ });
73
+
74
+ const hz = (
75
+ min: number,
76
+ max: number,
77
+ fallback: number,
78
+ doc: string,
79
+ ): NumberParam => ({
80
+ kind: "number",
81
+ min,
82
+ max,
83
+ default: fallback,
84
+ step: "log",
85
+ unit: "Hz",
86
+ doc,
87
+ });
88
+
89
+ const q = (doc: string): NumberParam => ({
90
+ kind: "number",
91
+ min: 0.1,
92
+ max: 10,
93
+ default: 1,
94
+ step: 0.1,
95
+ doc,
96
+ });
97
+
98
+ export const MASTER_SPECS: Readonly<Record<MasterUnit, MasterUnitSpec>> =
99
+ Object.freeze({
100
+ eq: {
101
+ label: "EQ",
102
+ doc: "low shelf, two bells and a high shelf; 0 dB bands are skipped",
103
+ simple: ["low", "bell1", "bell2", "high"],
104
+ params: {
105
+ low: db(-12, 12, 0, "low shelf gain"),
106
+ lowfreq: hz(20, 1000, 100, "low shelf corner"),
107
+ bell1: db(-12, 12, 0, "first bell gain"),
108
+ bell1freq: hz(40, 16_000, 400, "first bell centre"),
109
+ bell1q: q("first bell width: higher is narrower"),
110
+ bell2: db(-12, 12, 0, "second bell gain"),
111
+ bell2freq: hz(200, 18_000, 3_000, "second bell centre"),
112
+ bell2q: q("second bell width: higher is narrower"),
113
+ high: db(-12, 12, 0, "high shelf gain"),
114
+ highfreq: hz(1_000, 20_000, 10_000, "high shelf corner"),
115
+ },
116
+ },
117
+ glue: {
118
+ label: "glue",
119
+ doc: "stereo-linked bus compressor that holds the mix together",
120
+ simple: ["threshold", "ratio", "attack", "release", "makeup", "auto"],
121
+ params: {
122
+ threshold: db(-40, 0, -18, "level where compression starts", 1),
123
+ ratio: {
124
+ kind: "number",
125
+ min: 1,
126
+ max: 10,
127
+ default: 2,
128
+ step: 0.5,
129
+ doc: "input:output above threshold (2 and 4 are the classic bus settings)",
130
+ },
131
+ attack: {
132
+ kind: "number",
133
+ min: 0.1,
134
+ max: 30,
135
+ default: 10,
136
+ step: 0.5,
137
+ unit: "ms",
138
+ doc: "how fast it clamps down; slower lets transients through",
139
+ },
140
+ release: {
141
+ kind: "number",
142
+ min: 50,
143
+ max: 1_200,
144
+ default: 300,
145
+ step: 50,
146
+ unit: "ms",
147
+ doc: "how fast it lets go; set it to breathe with the tempo",
148
+ },
149
+ knee: db(0, 12, 6, "soft-knee width", 1),
150
+ makeup: db(0, 24, 0, "gain after compression (on top of auto)"),
151
+ auto: {
152
+ kind: "boolean",
153
+ default: true,
154
+ doc: "automatic make-up (half the reduction at 0 dBFS) so on/off compares near level-matched",
155
+ },
156
+ mix: {
157
+ kind: "number",
158
+ min: 0,
159
+ max: 1,
160
+ default: 1,
161
+ step: 0.05,
162
+ doc: "dry/wet: below 1 is parallel compression",
163
+ },
164
+ hpf: {
165
+ kind: "number",
166
+ min: 0,
167
+ max: 400,
168
+ default: 0,
169
+ step: 10,
170
+ unit: "Hz",
171
+ doc: "sidechain high-pass so the bass does not pump the mix; 0 is off",
172
+ },
173
+ },
174
+ },
175
+ tape: {
176
+ label: "tape",
177
+ doc: "tape-style saturation: soft clipping with bias, peaks held in place",
178
+ simple: ["drive", "mix"],
179
+ params: {
180
+ drive: db(0, 24, 6, "push into the curve: denser and louder"),
181
+ bias: {
182
+ kind: "number",
183
+ min: 0,
184
+ max: 0.5,
185
+ default: 0.1,
186
+ step: 0.05,
187
+ doc: "asymmetry: adds even harmonics",
188
+ },
189
+ tone: hz(
190
+ 2_000,
191
+ 20_000,
192
+ 20_000,
193
+ "high-frequency roll-off after the curve; 20000 is off",
194
+ ),
195
+ mix: {
196
+ kind: "number",
197
+ min: 0,
198
+ max: 1,
199
+ default: 1,
200
+ step: 0.05,
201
+ doc: "dry/wet",
202
+ },
203
+ },
204
+ },
205
+ width: {
206
+ label: "width",
207
+ doc: "mid/side stereo width with mono bass below a cutoff",
208
+ simple: ["width", "mono"],
209
+ params: {
210
+ width: {
211
+ kind: "number",
212
+ min: 0,
213
+ max: 2,
214
+ default: 1,
215
+ step: 0.05,
216
+ doc: "side level: 0 is mono, 1 unchanged, 2 twice as wide",
217
+ },
218
+ mono: {
219
+ kind: "number",
220
+ min: 0,
221
+ max: 300,
222
+ default: 120,
223
+ step: 10,
224
+ unit: "Hz",
225
+ doc: "below this the mix is mono (keeps bass centred); 0 is off",
226
+ },
227
+ },
228
+ },
229
+ limiter: {
230
+ label: "limiter",
231
+ doc: "true-peak brickwall limiter with lookahead",
232
+ simple: ["ceiling", "gain", "release"],
233
+ params: {
234
+ ceiling: {
235
+ kind: "number",
236
+ min: -12,
237
+ max: 0,
238
+ default: -1,
239
+ step: 0.1,
240
+ unit: "dBTP",
241
+ doc: "highest true peak out",
242
+ },
243
+ gain: db(0, 24, 0, "drive into the limiter (a target sets it itself)"),
244
+ release: {
245
+ kind: "number",
246
+ min: 1,
247
+ max: 1_000,
248
+ default: 100,
249
+ step: 10,
250
+ unit: "ms",
251
+ doc: "recovery time; short is louder, long is cleaner",
252
+ },
253
+ lookahead: {
254
+ kind: "number",
255
+ min: 0.5,
256
+ max: 10,
257
+ default: 5,
258
+ step: 0.5,
259
+ unit: "ms",
260
+ doc: "how early gain reduction starts before a peak",
261
+ },
262
+ truepeak: {
263
+ kind: "boolean",
264
+ default: true,
265
+ doc: "catch peaks between samples (4x oversampled detection)",
266
+ },
267
+ },
268
+ },
269
+ });
270
+
271
+ /**
272
+ * Named loudness targets: integrated LUFS, the limiter ceiling (dBTP) and,
273
+ * for loud targets, the limiter preset whose faster release gets there.
274
+ */
275
+ export const LOUDNESS_TARGETS = Object.freeze({
276
+ streaming: {
277
+ lufs: -14,
278
+ ceiling: -1,
279
+ doc: "Spotify, YouTube, Tidal and Amazon play back near -14",
280
+ },
281
+ apple: { lufs: -16, ceiling: -1, doc: "Apple Music Sound Check level" },
282
+ podcast: { lufs: -16, ceiling: -1, doc: "spoken word" },
283
+ broadcast: { lufs: -23, ceiling: -1, doc: "EBU R 128 broadcast level" },
284
+ club: {
285
+ lufs: -8,
286
+ // Masters louder than -14 LUFS stay under -2 dBTP: lossy encoding of
287
+ // dense, loud material makes inter-sample overs (Spotify's guidance).
288
+ ceiling: -2,
289
+ limiter: "loud",
290
+ doc: "club and DJ masters: trance, DnB, techno",
291
+ },
292
+ loud: {
293
+ lufs: -6,
294
+ ceiling: -2,
295
+ limiter: "brick",
296
+ doc: "loud hyperpop, gabber and hardcore masters",
297
+ },
298
+ classical: { lufs: -20, ceiling: -1, doc: "classical: keeps the dynamics" },
299
+ ambient: { lufs: -18, ceiling: -1, doc: "ambient and drone" },
300
+ } satisfies Record<
301
+ string,
302
+ { lufs: number; ceiling: number; limiter?: string; doc: string }
303
+ >);
304
+ export type LoudnessTargetName = keyof typeof LOUDNESS_TARGETS;
305
+ export const LOUDNESS_TARGET_NAMES = Object.freeze(
306
+ Object.keys(LOUDNESS_TARGETS) as LoudnessTargetName[],
307
+ );
308
+
309
+ export function isLoudnessTargetName(name: string): name is LoudnessTargetName {
310
+ return Object.prototype.hasOwnProperty.call(LOUDNESS_TARGETS, name);
311
+ }
312
+
313
+ /** Starting points per unit; every value stays editable. */
314
+ export const MASTER_PRESETS: Readonly<
315
+ Record<MasterUnit, Readonly<Record<string, FxValues>>>
316
+ > = Object.freeze({
317
+ eq: {
318
+ air: { high: 2, highfreq: 12_000 },
319
+ warm: { low: 1.5, lowfreq: 120, high: -1.5, highfreq: 8_000 },
320
+ "mud-cut": { bell1: -2.5, bell1freq: 300, bell1q: 1.2 },
321
+ smile: { low: 2, high: 2, bell1: -1.5, bell1freq: 800, bell1q: 0.7 },
322
+ },
323
+ glue: {
324
+ gentle: { threshold: -16, ratio: 2, attack: 30, release: 300 },
325
+ glue: { threshold: -20, ratio: 4, attack: 10, release: 100 },
326
+ pump: {
327
+ threshold: -26,
328
+ ratio: 10,
329
+ attack: 0.1,
330
+ release: 200,
331
+ hpf: 0,
332
+ },
333
+ },
334
+ tape: {
335
+ warm: { drive: 3, bias: 0.15, tone: 16_000 },
336
+ hot: { drive: 9, bias: 0.2, tone: 14_000 },
337
+ crush: { drive: 18, bias: 0.3, tone: 9_000 },
338
+ },
339
+ width: {
340
+ narrow: { width: 0.7, mono: 150 },
341
+ wide: { width: 1.4, mono: 120 },
342
+ vinyl: { width: 1, mono: 150 },
343
+ },
344
+ limiter: {
345
+ transparent: { release: 300, lookahead: 5 },
346
+ loud: { release: 60, lookahead: 2 },
347
+ brick: { release: 20, lookahead: 1 },
348
+ },
349
+ });
350
+
351
+ export function isMasterUnit(name: string): name is MasterUnit {
352
+ return (MASTER_UNITS as readonly string[]).includes(name);
353
+ }
354
+
355
+ /** A unit's full default values, as `master <unit> on` stores them. */
356
+ export function masterDefaults(unit: MasterUnit): FxValues {
357
+ return normalizeParams(MASTER_SPECS[unit].params, {}, `master ${unit}`);
358
+ }
359
+
360
+ /** Whether the master changes anything (some unit on or a target). */
361
+ export function masterActive(master: SongMaster | undefined): boolean {
362
+ return (
363
+ master !== undefined &&
364
+ (master.target !== undefined ||
365
+ MASTER_UNITS.some((unit) => master[unit] !== undefined))
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Validates a master: units fill every default, unknown keys are rejected
371
+ * so typos never pass silently. An empty master normalizes to `undefined`.
372
+ */
373
+ export function normalizeMaster(input: unknown): SongMaster | undefined {
374
+ if (input === undefined || input === null) return undefined;
375
+ if (!isRecord(input)) throw new FxValidationError("master must be an object");
376
+ for (const key of Object.keys(input))
377
+ if (key !== "target" && !isMasterUnit(key))
378
+ throw new FxValidationError(
379
+ `master has no "${key}"; use ${[...MASTER_UNITS, "target"].join(", ")}`,
380
+ );
381
+ const out: Record<string, FxValues | number> = {};
382
+ for (const unit of MASTER_UNITS) {
383
+ const value = input[unit];
384
+ if (value === undefined || value === null) continue;
385
+ out[unit] = normalizeParams(
386
+ MASTER_SPECS[unit].params,
387
+ value,
388
+ `master ${unit}`,
389
+ );
390
+ }
391
+ const target = input.target;
392
+ if (target !== undefined && target !== null) {
393
+ if (
394
+ typeof target !== "number" ||
395
+ !Number.isFinite(target) ||
396
+ target < MASTER_LIMITS.minTarget ||
397
+ target > MASTER_LIMITS.maxTarget
398
+ )
399
+ throw new FxValidationError(
400
+ `master target must be ${MASTER_LIMITS.minTarget}..${MASTER_LIMITS.maxTarget} LUFS`,
401
+ );
402
+ out.target = target;
403
+ }
404
+ return Object.keys(out).length > 0
405
+ ? (Object.freeze(out) as SongMaster)
406
+ : undefined;
407
+ }
408
+
409
+ /** `eq low 2 · limiter ceiling -1 · target -14 LUFS`, for receipts. */
410
+ export function describeMaster(master: SongMaster | undefined): string {
411
+ if (!masterActive(master)) return "off";
412
+ const parts: string[] = [];
413
+ for (const unit of MASTER_UNITS) {
414
+ const values = master![unit];
415
+ if (!values) continue;
416
+ parts.push(`${unit} ${describeUnit(unit, values)}`.trim());
417
+ }
418
+ if (master!.target !== undefined)
419
+ parts.push(`target ${formatNumber(master!.target)} LUFS`);
420
+ return parts.join(" · ");
421
+ }
422
+
423
+ /** The non-default values of one unit, or its simple ones when all are. */
424
+ export function describeUnit(unit: MasterUnit, values: FxValues): string {
425
+ const spec = MASTER_SPECS[unit];
426
+ const changed = Object.entries(spec.params).filter(
427
+ ([key, param]) =>
428
+ values[key] !== undefined && values[key] !== param.default,
429
+ );
430
+ const shown =
431
+ changed.length > 0
432
+ ? changed.map(([key]) => key)
433
+ : spec.simple.filter((key) => values[key] !== undefined);
434
+ return shown
435
+ .map((key) => {
436
+ const value = values[key]!;
437
+ const param = spec.params[key]!;
438
+ const text =
439
+ typeof value === "number"
440
+ ? formatNumber(value)
441
+ : typeof value === "boolean"
442
+ ? value
443
+ ? "on"
444
+ : "off"
445
+ : value;
446
+ return param.kind === "number" && param.unit
447
+ ? `${key} ${text} ${param.unit}`
448
+ : `${key} ${text}`;
449
+ })
450
+ .join(", ");
451
+ }
452
+
453
+ function formatNumber(value: number): string {
454
+ return String(Math.round(value * 1000) / 1000);
455
+ }