@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Numeric pattern checks on a generated style (quality-08). Each check
3
+ * measures the score the generator produced against the card that drove
4
+ * it: meter and tempo, onsets on the swung grid (within the groove's
5
+ * microtiming and humanise tolerance), the measured swing ratio, scale
6
+ * and tuning membership, acceptance by the card's harmony grammar,
7
+ * melody range and interval statistics, and that every instrument and
8
+ * kit resolves. Family lanes rely on `validateGenerated` through
9
+ * `cards.test.ts`; `/style info --check` and the agent show the same
10
+ * report.
11
+ */
12
+
13
+ import { createScore } from "../score.ts";
14
+
15
+ import {
16
+ generateStyle,
17
+ harmonyGrammar,
18
+ modeOfScale as modeOfScaleName,
19
+ onsetTolerance,
20
+ stripSeventh,
21
+ swungStepTick,
22
+ type GeneratedStyle,
23
+ type PlannedChord,
24
+ type StylePlan,
25
+ } from "./generate.ts";
26
+ import { resolveInstrumentWord } from "../instruments.ts";
27
+ import { SYNTH_KIT_NAMES } from "../kits.ts";
28
+ import { roleGrid } from "./index.ts";
29
+ import {
30
+ KIT_ROLES,
31
+ PERC_ROLES,
32
+ PITCHED_ROLES,
33
+ type RoleName,
34
+ type StyleId,
35
+ type StyleOptions,
36
+ } from "./schema.ts";
37
+
38
+ export type StyleCheck = Readonly<{
39
+ name: string;
40
+ ok: boolean;
41
+ /** The measured value, when the check measures one. */
42
+ value?: number;
43
+ detail: string;
44
+ }>;
45
+
46
+ export type StyleReport = Readonly<{
47
+ id: StyleId;
48
+ seed: number;
49
+ bars: number;
50
+ ok: boolean;
51
+ checks: readonly StyleCheck[];
52
+ }>;
53
+
54
+ /** Fraction of pitched notes that may sit outside scale and chord. */
55
+ export const VALIDATE_LIMITS = Object.freeze({
56
+ /** Lead and counter notes in the scale or the sounding chord. */
57
+ melodyMembership: 0.9,
58
+ /** Absolute tolerance on the measured swing ratio. */
59
+ swingTolerance: 0.2,
60
+ /** Odd-step kit notes needed before swing is measured. */
61
+ swingSamples: 6,
62
+ /** Largest melodic leap inside a phrase (an octave). */
63
+ maxLeap: 12.5,
64
+ });
65
+
66
+ const KIT = new Set<string>(KIT_ROLES);
67
+ const PERC = new Set<string>(PERC_ROLES);
68
+ const PITCHED = new Set<string>(PITCHED_ROLES);
69
+ type CheckedNote = Readonly<{
70
+ id: string;
71
+ trackId: string;
72
+ startTick: number;
73
+ durationTicks: number;
74
+ pitch: number;
75
+ velocity: number;
76
+ cents?: number;
77
+ }>;
78
+
79
+ const NOTES = new WeakMap<object, readonly CheckedNote[]>();
80
+
81
+ /** The generated notes with their tick fields required (the generator always writes ticks). */
82
+ function notesOf(data: GeneratedStyle["data"]): readonly CheckedNote[] {
83
+ const cached = NOTES.get(data);
84
+ if (cached) return cached;
85
+ const notes = (data.notes ?? []).map((note) => ({
86
+ id: note.id,
87
+ trackId: note.trackId,
88
+ startTick: note.startTick ?? NaN,
89
+ durationTicks: note.durationTicks ?? NaN,
90
+ pitch: note.pitch,
91
+ velocity: note.velocity,
92
+ ...(note.cents !== undefined ? { cents: note.cents } : {}),
93
+ }));
94
+ NOTES.set(data, notes);
95
+ return notes;
96
+ }
97
+
98
+ const mod = (n: number, m: number) => ((n % m) + m) % m;
99
+
100
+ function check(
101
+ name: string,
102
+ ok: boolean,
103
+ detail: string,
104
+ value?: number,
105
+ ): StyleCheck {
106
+ return Object.freeze({
107
+ name,
108
+ ok,
109
+ detail,
110
+ ...(value !== undefined ? { value: Math.round(value * 1000) / 1000 } : {}),
111
+ });
112
+ }
113
+
114
+ /** Generate `id` and validate the result. */
115
+ export function validateStyle(
116
+ id: StyleId,
117
+ options: StyleOptions = {},
118
+ ): StyleReport {
119
+ return validateGenerated(generateStyle(id, options));
120
+ }
121
+
122
+ /** Every pattern check on one generated style. */
123
+ export function validateGenerated(generated: GeneratedStyle): StyleReport {
124
+ const { plan, data } = generated;
125
+ const checks: StyleCheck[] = [
126
+ checkStructure(generated),
127
+ checkMeter(plan, data),
128
+ checkTempo(plan, data.tempoBpm ?? NaN),
129
+ ...checkOnsets(generated),
130
+ checkTuning(generated),
131
+ ...checkPitch(generated),
132
+ checkGrammar(plan),
133
+ ...checkMelody(generated),
134
+ checkInstruments(generated),
135
+ ];
136
+ return Object.freeze({
137
+ id: plan.style.id,
138
+ seed: plan.seed,
139
+ bars: plan.bars,
140
+ ok: checks.every((entry) => entry.ok),
141
+ checks: Object.freeze(checks),
142
+ });
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function checkStructure({ plan, data }: GeneratedStyle): StyleCheck {
148
+ const loop = plan.bars * plan.barTicks;
149
+ const bad = notesOf(data).filter(
150
+ (note) =>
151
+ !Number.isInteger(note.startTick) ||
152
+ note.startTick < 0 ||
153
+ note.startTick >= loop ||
154
+ !(note.durationTicks >= 1) ||
155
+ note.startTick + note.durationTicks > loop ||
156
+ !(note.velocity > 0 && note.velocity <= 1) ||
157
+ note.pitch < 0 ||
158
+ note.pitch > 127,
159
+ );
160
+ const tracks = new Set((data.tracks ?? []).map((track) => track.id));
161
+ const orphan = notesOf(data).filter((note) => !tracks.has(note.trackId));
162
+ let parsed = true;
163
+ try {
164
+ createScore(data);
165
+ } catch {
166
+ parsed = false;
167
+ }
168
+ const ok =
169
+ notesOf(data).length > 0 &&
170
+ bad.length === 0 &&
171
+ orphan.length === 0 &&
172
+ parsed &&
173
+ data.bars === plan.bars;
174
+ return check(
175
+ "structure",
176
+ ok,
177
+ `${notesOf(data).length} notes, ${bad.length} out of bounds, ${orphan.length} orphaned, score ${parsed ? "loads" : "rejected"}`,
178
+ notesOf(data).length,
179
+ );
180
+ }
181
+
182
+ function checkMeter(plan: StylePlan, data: GeneratedStyle["data"]): StyleCheck {
183
+ const allowed = plan.style.meter.signatures.map(([sig]) => sig);
184
+ const meter = data.time?.meter?.[0];
185
+ const unit = meter ? meter.beatUnit : 4;
186
+ const beats = meter ? meter.beatsPerBar : data.beatsPerBar;
187
+ const ok =
188
+ allowed.includes(plan.signature) &&
189
+ beats === plan.beatsPerBar &&
190
+ unit === plan.beatUnit &&
191
+ data.beatsPerBar === plan.beatsPerBar;
192
+ return check(
193
+ "meter",
194
+ ok,
195
+ `${beats}/${unit}; card allows ${allowed.join(", ")}`,
196
+ );
197
+ }
198
+
199
+ function checkTempo(plan: StylePlan, bpm: number): StyleCheck {
200
+ const [low, high] = plan.style.tempo.bpm;
201
+ const ok =
202
+ Number.isFinite(bpm) &&
203
+ bpm >= Math.floor(low) &&
204
+ bpm <= Math.ceil(high) &&
205
+ bpm >= 20 &&
206
+ bpm <= 300;
207
+ return check("tempo", ok, `${bpm} bpm in ${low}-${high}`, bpm);
208
+ }
209
+
210
+ /** Steps of the last beat of each fill bar. */
211
+ function fillSteps(plan: StylePlan): Set<number> {
212
+ const out = new Set<number>();
213
+ const fills = plan.style.rhythm.fills;
214
+ if (!fills) return out;
215
+ const beat = Math.max(1, Math.round(plan.stepsPerBar / plan.beatsPerBar));
216
+ for (let bar = fills.every - 1; bar < plan.bars; bar += fills.every)
217
+ for (
218
+ let s = (bar + 1) * plan.stepsPerBar - beat;
219
+ s < (bar + 1) * plan.stepsPerBar;
220
+ s += 1
221
+ )
222
+ out.add(s);
223
+ return out;
224
+ }
225
+
226
+ function checkOnsets({ plan, data }: GeneratedStyle): StyleCheck[] {
227
+ const style = plan.style;
228
+ const fills = fillSteps(plan);
229
+ const cycle = style.meter.cycle;
230
+ const pulse = (480 * 4) / plan.beatUnit;
231
+ let total = 0;
232
+ let off = 0;
233
+ let offGrid = 0;
234
+ let worst = 0;
235
+ const swingShifts: number[] = [];
236
+ for (const note of notesOf(data)) {
237
+ const role = plan.noteRoles.get(note.id) as RoleName | undefined;
238
+ if (!role || !(KIT.has(role) || PERC.has(role))) continue;
239
+ total += 1;
240
+ if (role === "perc" && cycle) {
241
+ const i = Math.round(note.startTick / pulse);
242
+ const error = Math.abs(note.startTick - i * pulse);
243
+ worst = Math.max(worst, error);
244
+ if (error > onsetTolerance(plan, role)) off += 1;
245
+ if (cycle.strokes[mod(i, cycle.beats)] === ".") offGrid += 1;
246
+ continue;
247
+ }
248
+ // Nearest grid step under the swung grid.
249
+ const raw = Math.round(note.startTick / plan.stepTicks);
250
+ let step = raw;
251
+ let error = Infinity;
252
+ for (const s of [raw - 1, raw, raw + 1]) {
253
+ if (s < 0) continue;
254
+ const e = Math.abs(note.startTick - swungStepTick(plan, s));
255
+ if (e < error) {
256
+ error = e;
257
+ step = s;
258
+ }
259
+ }
260
+ worst = Math.max(worst, error);
261
+ if (error > onsetTolerance(plan, role)) off += 1;
262
+ const grid = roleGrid(style.rhythm.onsets[role], role, plan);
263
+ const inGrid = grid ? grid[mod(step, plan.stepsPerBar)]! > 0 : false;
264
+ const isFill = fills.has(step) && (role === "snare" || role === "tom");
265
+ if (!inGrid && !isFill) offGrid += 1;
266
+ if (
267
+ step % 2 === 1 &&
268
+ plan.subdivision % 2 === 0 &&
269
+ !style.groove.microtiming?.length &&
270
+ !style.groove.roleOffset?.[role]
271
+ )
272
+ swingShifts.push(note.startTick - step * plan.stepTicks);
273
+ }
274
+ const out = [
275
+ check(
276
+ "onset-grid",
277
+ off === 0 && offGrid === 0,
278
+ `${total} kit/perc onsets: ${off} off the swung grid (worst ${Math.round(worst)} ticks), ${offGrid} on steps the card's grid never plays`,
279
+ total ? (total - off - offGrid) / total : 1,
280
+ ),
281
+ ];
282
+ // Swing: the median shift of odd steps gives the long:short ratio.
283
+ if (
284
+ swingShifts.length >= VALIDATE_LIMITS.swingSamples &&
285
+ plan.subdivision % 2 === 0
286
+ ) {
287
+ const sorted = swingShifts.sort((a, b) => a - b);
288
+ const shift = sorted[Math.floor(sorted.length / 2)]! / plan.stepTicks;
289
+ const ratio = (1 + shift) / Math.max(1e-6, 1 - shift);
290
+ const [low, high] = style.groove.swingRatio;
291
+ const tolerance = VALIDATE_LIMITS.swingTolerance;
292
+ out.push(
293
+ check(
294
+ "swing",
295
+ ratio >= low - tolerance &&
296
+ ratio <= high + tolerance &&
297
+ Math.abs(ratio - plan.swing) <= tolerance,
298
+ `measured ${ratio.toFixed(2)}:1 over ${swingShifts.length} off-steps; card ${low}-${high}, drawn ${plan.swing.toFixed(2)}`,
299
+ ratio,
300
+ ),
301
+ );
302
+ }
303
+ return out;
304
+ }
305
+
306
+ function checkTuning({ plan, data }: GeneratedStyle): StyleCheck {
307
+ const wanted = plan.style.pitch.tuning;
308
+ if (!wanted) {
309
+ const ok = !data.tuning;
310
+ return check("tuning", ok, ok ? "12-TET" : "unexpected tuning");
311
+ }
312
+ const ok =
313
+ !!data.tuning &&
314
+ data.tuning.name === plan.tuning?.name &&
315
+ data.tuning.root === plan.rootKey &&
316
+ notesOf(data).every((note) => !note.cents);
317
+ return check(
318
+ "tuning",
319
+ ok,
320
+ `${data.tuning?.name ?? "none"} rooted on ${data.tuning?.root ?? "-"}; card wants ${wanted}`,
321
+ );
322
+ }
323
+
324
+ function chordAtTick(plan: StylePlan, tick: number): PlannedChord | undefined {
325
+ const bar = tick / plan.barTicks;
326
+ for (let i = plan.chords.length - 1; i >= 0; i -= 1)
327
+ if (bar + 1e-9 >= plan.chords[i]!.bar) return plan.chords[i];
328
+ return plan.chords[0];
329
+ }
330
+
331
+ /** Sounding semitone class above the tonic for a key (tuning-aware). */
332
+ function pitchClassOf(plan: StylePlan, key: number, cents: number): number {
333
+ const scale = plan.scale;
334
+ if (scale.period !== 12) {
335
+ const offset = key - plan.rootKey;
336
+ const index = mod(offset, scale.period);
337
+ const tone = scale.tones.find((t) => t.key === index);
338
+ return tone ? mod(tone.semis, scale.periodSemis) : NaN;
339
+ }
340
+ if (plan.tuning) {
341
+ // A 12-key tuning table: the key's degree carries the table's cents.
342
+ const step = mod(key - plan.rootKey, 12);
343
+ const tone = scale.tones.find((t) => t.key === step);
344
+ return tone ? mod(tone.semis, 12) : step;
345
+ }
346
+ return mod(key - plan.rootKey + cents / 100, 12);
347
+ }
348
+
349
+ function checkPitch(generated: GeneratedStyle): StyleCheck[] {
350
+ const { plan, data } = generated;
351
+ const scale = plan.scale;
352
+ const inScale = (pc: number) =>
353
+ scale.tones.some(
354
+ (t) =>
355
+ Math.abs(mod(t.semis, scale.periodSemis) - pc) < 0.02 ||
356
+ Math.abs(mod(t.semis, scale.periodSemis) - pc - scale.periodSemis) <
357
+ 0.02 ||
358
+ Math.abs(mod(t.semis, scale.periodSemis) - pc + scale.periodSemis) <
359
+ 0.02,
360
+ );
361
+ // Power chords are root and perfect fifth whatever the triad's fifth.
362
+ const power = plan.style.harmony.voicing.types.some(([t]) => t === "power");
363
+ const walking = plan.style.bass.behaviour.some(([b]) => b === "walking");
364
+ let melodyTotal = 0;
365
+ let melodyIn = 0;
366
+ let strictTotal = 0;
367
+ let strictOut = 0;
368
+ const strays: string[] = [];
369
+ for (const note of notesOf(data)) {
370
+ const role = plan.noteRoles.get(note.id) as RoleName | undefined;
371
+ if (!role || !PITCHED.has(role)) continue;
372
+ const pc = pitchClassOf(plan, note.pitch, note.cents ?? 0);
373
+ // The chord sounding at the note, or the one it anticipates by up to
374
+ // half a step (humanised and pushed notes land just before the change).
375
+ const inChord =
376
+ scale.period === 12 &&
377
+ [note.startTick, note.startTick + plan.stepTicks / 2].some((tick) => {
378
+ const chord = chordAtTick(plan, tick);
379
+ return (
380
+ !!chord &&
381
+ !chord.degrees &&
382
+ [...chord.pcs, ...(power ? [chord.rootPc + 7] : [])].some(
383
+ (p) => Math.abs(mod(p - plan.tonic, 12) - pc) < 0.02,
384
+ )
385
+ );
386
+ });
387
+ const ok = Number.isFinite(pc) && (inScale(pc) || inChord);
388
+ if (role === "lead" || role === "counter") {
389
+ melodyTotal += 1;
390
+ if (ok) melodyIn += 1;
391
+ continue;
392
+ }
393
+ if (role === "bass" && walking) {
394
+ // Walking bass: chromatic approach tones are idiomatic.
395
+ if (!ok && !Number.isInteger(Math.round(pc * 1000) / 1000)) {
396
+ strictTotal += 1;
397
+ strictOut += 1;
398
+ }
399
+ continue;
400
+ }
401
+ strictTotal += 1;
402
+ if (!ok) {
403
+ strictOut += 1;
404
+ if (strays.length < 4)
405
+ strays.push(`${role}@${note.startTick}:${pc.toFixed(2)}`);
406
+ }
407
+ }
408
+ const membership = melodyTotal ? melodyIn / melodyTotal : 1;
409
+ return [
410
+ check(
411
+ "scale",
412
+ strictOut === 0,
413
+ `${strictTotal} harmony/bass notes, ${strictOut} outside ${scale.name} and the sounding chord${strays.length ? ` (${strays.join(" ")})` : ""}`,
414
+ strictTotal ? 1 - strictOut / strictTotal : 1,
415
+ ),
416
+ check(
417
+ "melody-scale",
418
+ membership >= VALIDATE_LIMITS.melodyMembership,
419
+ `${melodyIn}/${melodyTotal} melody notes in scale or chord`,
420
+ membership,
421
+ ),
422
+ ];
423
+ }
424
+
425
+ function checkGrammar(plan: StylePlan): StyleCheck {
426
+ if (plan.harmonyModel !== "functional" || plan.scale.period !== 12)
427
+ return check("grammar", true, `${plan.harmonyModel} harmony: no grammar`);
428
+ const grammar = harmonyGrammar(plan.style, modeOfScaleName(plan.scaleName));
429
+ const numerals = plan.chords.map((chord) => stripSeventh(chord.numeral));
430
+ const unknown = numerals.filter((n) => !grammar.numerals.has(n));
431
+ const badEdges: string[] = [];
432
+ for (let i = 0; i + 1 < numerals.length; i += 1) {
433
+ const edge = `${numerals[i]}>${numerals[i + 1]}`;
434
+ if (!grammar.edges.has(edge)) badEdges.push(edge);
435
+ }
436
+ const ok = unknown.length === 0 && badEdges.length === 0;
437
+ return check(
438
+ "grammar",
439
+ ok,
440
+ ok
441
+ ? `${numerals.length} chords accepted (${plan.harmonySource})`
442
+ : `rejected: ${[...new Set([...unknown, ...badEdges])].slice(0, 5).join(" ")} (${plan.harmonySource})`,
443
+ numerals.length
444
+ ? 1 - (unknown.length + badEdges.length) / numerals.length
445
+ : 1,
446
+ );
447
+ }
448
+
449
+ function checkMelody({ plan, data }: GeneratedStyle): StyleCheck[] {
450
+ const melody = plan.style.melody;
451
+ const lead = notesOf(data)
452
+ .filter((note) => plan.noteRoles.get(note.id) === "lead")
453
+ .sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
454
+ if (lead.length < 2) return [];
455
+ const sounding = (note: (typeof lead)[number]) =>
456
+ note.pitch + (note.cents ?? 0) / 100;
457
+ const [low, high] = melody.range;
458
+ const outside = lead.filter(
459
+ (note) =>
460
+ note.pitch < Math.round(low) - 1 || note.pitch > Math.round(high) + 1,
461
+ );
462
+ const intervals: number[] = [];
463
+ // Intervals inside a phrase; the jump between phrases is a new start.
464
+ const phraseOf = (tick: number) => {
465
+ const bar = tick / plan.barTicks;
466
+ let index = 0;
467
+ plan.phrases.forEach((phrase, i) => {
468
+ if (bar + 1e-9 >= phrase.startBar) index = i;
469
+ });
470
+ return index;
471
+ };
472
+ for (let i = 1; i < lead.length; i += 1)
473
+ if (phraseOf(lead[i]!.startTick) === phraseOf(lead[i - 1]!.startTick))
474
+ intervals.push(sounding(lead[i]!) - sounding(lead[i - 1]!));
475
+ if (intervals.length === 0) intervals.push(0);
476
+ const abs = intervals.map(Math.abs);
477
+ const mean = abs.reduce((a, b) => a + b, 0) / abs.length;
478
+ const maxLeap = Math.max(...abs);
479
+ // Expected mean |interval| from the card's weights (-12..12).
480
+ const weights = melody.intervals;
481
+ let wsum = 0;
482
+ let expect = 0;
483
+ weights.forEach((w, i) => {
484
+ wsum += w;
485
+ expect += w * Math.abs(i - 12);
486
+ });
487
+ expect = wsum > 0 ? expect / wsum : 2;
488
+ const stepScale =
489
+ plan.scale.period === 12
490
+ ? 1
491
+ : plan.scale.periodSemis / plan.scale.tones.length;
492
+ return [
493
+ check(
494
+ "melody-range",
495
+ outside.length === 0,
496
+ `${lead.length} lead notes in ${low}-${high}: ${outside.length} outside`,
497
+ outside.length,
498
+ ),
499
+ check(
500
+ "melody-intervals",
501
+ maxLeap <= VALIDATE_LIMITS.maxLeap &&
502
+ mean <= Math.max(expect * 2.2, 3 * stepScale) + 1,
503
+ `mean |interval| ${mean.toFixed(2)} (card ~${expect.toFixed(2)}), largest ${maxLeap.toFixed(1)}`,
504
+ mean,
505
+ ),
506
+ ];
507
+ }
508
+
509
+ function checkInstruments({ plan, data }: GeneratedStyle): StyleCheck {
510
+ const bad: string[] = [];
511
+ for (const track of plan.tracks) {
512
+ if (track.instrument === "drums") {
513
+ const kit = track.voice.kit;
514
+ if (kit && !SYNTH_KIT_NAMES.includes(kit)) bad.push(`kit ${kit}`);
515
+ continue;
516
+ }
517
+ if (!resolveInstrumentWord(track.instrument)) bad.push(track.instrument);
518
+ }
519
+ if ((data.tracks ?? []).length !== plan.tracks.length)
520
+ bad.push("track count");
521
+ return check(
522
+ "instruments",
523
+ bad.length === 0,
524
+ bad.length
525
+ ? `unresolved: ${bad.join(", ")}`
526
+ : `${plan.tracks.map((t) => t.voice.kit ?? t.instrument).join(", ")}`,
527
+ );
528
+ }
package/core/tempo.ts CHANGED
@@ -107,8 +107,8 @@ export const TIME_LIMITS = Object.freeze({
107
107
  /** Same as `SCORE_LIMITS.minTempoBpm` / `maxTempoBpm`. */
108
108
  minBpm: 20,
109
109
  maxBpm: 300,
110
- /** Same as `SCORE_LIMITS.maxTick`. */
111
- maxTick: 1_000_000,
110
+ /** Same as `SCORE_LIMITS.maxTick` (2^26). */
111
+ maxTick: 67_108_864,
112
112
  /** Same as `SCORE_LIMITS.maxBeatsPerBar`. */
113
113
  maxBeatsPerBar: 16,
114
114
  /** Same as `SCORE_LIMITS.maxBars`. */
@@ -1098,6 +1098,36 @@ export function withoutFermatas(
1098
1098
  });
1099
1099
  }
1100
1100
 
1101
+ /**
1102
+ * The song time as the SDK can write it: tempo events and fermatas at or
1103
+ * past the final barline and meter changes from bar `bars` on are dropped
1104
+ * (never heard; the SDK refuses them), and a ramp landing on the final
1105
+ * barline moves to the last tick, as the SDK's `ramp()` does. A shorter
1106
+ * song applies this, and the printer applies it to older scores.
1107
+ */
1108
+ export function timeWithinSong(score: TimeScore): SongTime | undefined {
1109
+ const time = score.time;
1110
+ if (!time) return time;
1111
+ const end = loopTicksOf(score);
1112
+ const tempo: TempoEvent[] = [];
1113
+ for (const event of time.tempo ?? []) {
1114
+ const previous = tempo[tempo.length - 1]?.tick ?? 0;
1115
+ if (event.tick < end) tempo.push(event);
1116
+ else if (event.tick === end && event.ramp && end - 1 > previous)
1117
+ tempo.push({ ...event, tick: end - 1 });
1118
+ }
1119
+ const meter = (time.meter ?? []).filter((m) => m.bar < score.bars);
1120
+ const fermatas = (time.fermatas ?? []).filter((f) => f.tick < end);
1121
+ if (
1122
+ tempo.every((e, i) => e === time.tempo?.[i]) &&
1123
+ tempo.length === (time.tempo ?? []).length &&
1124
+ meter.length === (time.meter ?? []).length &&
1125
+ fermatas.length === (time.fermatas ?? []).length
1126
+ )
1127
+ return time;
1128
+ return rebuild(time, { tempo, meter, fermatas });
1129
+ }
1130
+
1101
1131
  /** Sets the meter from bar `bar` (0-based) on; null removes that change. */
1102
1132
  export function withMeterChange(
1103
1133
  time: SongTime | undefined,
package/core/tuning.ts CHANGED
@@ -264,7 +264,7 @@ const FIXED_PRESETS: readonly TuningPreset[] = [
264
264
  {
265
265
  name: "12-tet",
266
266
  family: "equal",
267
- about: "twelve-tone equal temperament, the default",
267
+ about: "twelve equal steps per octave (12-TET), the default",
268
268
  cents: edoCents(12),
269
269
  aliases: ["12edo", "12-edo", "12tet", "12-et", "equal", "et", "standard"],
270
270
  },
@@ -339,6 +339,15 @@ const FIXED_PRESETS: readonly TuningPreset[] = [
339
339
  cents: [231, 474, 717, 955, 1200],
340
340
  approximate: true,
341
341
  },
342
+ {
343
+ name: "thai",
344
+ family: "equal",
345
+ about:
346
+ "Thai and Khmer seven-tone equidistant tuning, about 171 cents a step (Morton's theoretical norm; every ensemble differs)",
347
+ cents: edoCents(7),
348
+ approximate: true,
349
+ aliases: ["7-edo", "7edo", "7-tet", "khmer"],
350
+ },
342
351
  {
343
352
  name: "nyamaropa",
344
353
  family: "african",
@@ -375,6 +384,9 @@ const FIXED_PRESETS: readonly TuningPreset[] = [
375
384
  function scalePreset(name: string, info: ScaleInfo): TuningPreset | undefined {
376
385
  const quarter = info.steps.some((step) => !Number.isInteger(step));
377
386
  if (!quarter && !info.intonation) return undefined;
387
+ // Quarter-tone art scales keep both a key and its shadow: note cents
388
+ // carry them, so no twelve-key table can.
389
+ if (info.family === "quarter-tone") return undefined;
378
390
  const raga = info.family === "raga";
379
391
  const table = raga ? fromRatios(HINDUSTANI) : edoCents(12);
380
392
  const keys = [0, ...table.slice(0, 11)];
@@ -390,10 +402,14 @@ function scalePreset(name: string, info: ScaleInfo): TuningPreset | undefined {
390
402
  ? "raga"
391
403
  : info.family === "dastgah"
392
404
  ? "dastgah"
393
- : "maqam",
405
+ : info.family === "overtone"
406
+ ? "just"
407
+ : "maqam",
394
408
  about: raga
395
409
  ? `raga ${name} with its shruti intonation over the Hindustani just table`
396
- : `${info.family} ${name} quarter tones on the twelve keys (24-tone convention)`,
410
+ : info.family === "overtone"
411
+ ? `partials 8 to 15 of the harmonic series over the tonic, in just cents`
412
+ : `${info.family} ${name} quarter tones on the twelve keys (24-tone convention)`,
397
413
  cents: [...keys.slice(1), 1200],
398
414
  approximate: true,
399
415
  };