@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,2199 @@
1
+ /**
2
+ * Style generation (quality-08): a resolved style card, a seed and a bar
3
+ * count become a song. Everything comes from the card's theory: meter and
4
+ * grouping, a groove grid with swing and microtiming, onset probability
5
+ * grids per role, a scale (12-TET, maqam quarter tones as cents, or a
6
+ * named tuning's degrees), a harmonic grammar (presets, fixed forms, a
7
+ * numeral Markov chain, cadences; or a modal vamp or a drone), a bass
8
+ * behaviour, a contour-driven melody over interval weights, a section plan
9
+ * and a mix. The same style, seed and bars always produce the same notes.
10
+ *
11
+ * The result carries the plan (`StylePlan`) next to the score data so the
12
+ * validator (`validate.ts`) can check the notes against the theory that
13
+ * made them.
14
+ */
15
+
16
+ import {
17
+ findPreset,
18
+ keyName,
19
+ MODES,
20
+ mulberry32,
21
+ parseKey,
22
+ parseRoman,
23
+ chordPitchClasses,
24
+ SCALES,
25
+ voiceChord,
26
+ type Key,
27
+ type ModeName,
28
+ type ScaleName,
29
+ type Spread,
30
+ } from "../chords.ts";
31
+ import { DRUM_VOICES } from "../drums.ts";
32
+ import { FX_PRESETS, applyRigPreset, rigReverb, type TrackFx } from "../fx.ts";
33
+ import { resolveInstrumentWord } from "../instruments.ts";
34
+ import { instrumentPatchForWord } from "../resonators.ts";
35
+ import {
36
+ createScore,
37
+ DEFAULT_TICKS_PER_BEAT,
38
+ SCORE_LIMITS,
39
+ type NoteInput,
40
+ type ScoreOperation,
41
+ type Section,
42
+ type TrackInput,
43
+ type TrackScore,
44
+ type TrackScoreData,
45
+ } from "../score.ts";
46
+ import {
47
+ isLoudnessTargetName,
48
+ LOUDNESS_TARGETS,
49
+ MASTER_PRESETS,
50
+ type SongMaster,
51
+ } from "../master.ts";
52
+ import { tuningPreset, type Tuning } from "../tuning.ts";
53
+ import { resolveStyle, roleGrid, stretchGrid, STYLE_TREE } from "./index.ts";
54
+ import {
55
+ KIT_ROLES,
56
+ PERC_ROLES,
57
+ PITCHED_ROLES,
58
+ ROLE_NAMES,
59
+ type CycleSpec,
60
+ type ResolvedStyle,
61
+ type RoleName,
62
+ type RoleVoice,
63
+ type SectionKind,
64
+ type StyleId,
65
+ type StyleOptions,
66
+ type StyleProvenance,
67
+ type Weighted,
68
+ } from "./schema.ts";
69
+
70
+ export class StyleError extends Error {
71
+ override name = "StyleError";
72
+ }
73
+
74
+ export const STYLE_LIMITS = Object.freeze({
75
+ minBars: 1,
76
+ maxBars: 64,
77
+ defaultBars: 8,
78
+ maxSeed: 2 ** 31 - 1,
79
+ });
80
+
81
+ const TPB = DEFAULT_TICKS_PER_BEAT;
82
+
83
+ /** Kit role -> General MIDI key the kit track plays. */
84
+ export const KIT_PITCH: Readonly<Record<string, number>> = Object.freeze(
85
+ Object.fromEntries(DRUM_VOICES.map((voice) => [voice.voice, voice.pitch])),
86
+ );
87
+
88
+ /** Perc roles played on a kit track (instrument "drums"). */
89
+ const PERC_ON_KIT: Readonly<Record<string, number>> = Object.freeze({
90
+ perc: KIT_PITCH.tom!,
91
+ shaker: KIT_PITCH.hat!,
92
+ bell: KIT_PITCH.rim!,
93
+ });
94
+
95
+ const LOW_STROKES = Object.freeze([
96
+ "dum",
97
+ "dha",
98
+ "dhin",
99
+ "ge",
100
+ "gong",
101
+ "kempul",
102
+ "doum",
103
+ "bum",
104
+ ]);
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Seeded helpers
108
+
109
+ /** FNV-1a over a string: stable 32-bit hash for seeding. */
110
+ export function hashText(text: string): number {
111
+ let hash = 0x811c9dc5;
112
+ for (let i = 0; i < text.length; i += 1) {
113
+ hash ^= text.charCodeAt(i);
114
+ hash = Math.imul(hash, 0x01000193) >>> 0;
115
+ }
116
+ return hash >>> 0;
117
+ }
118
+
119
+ type Random = () => number;
120
+
121
+ /** A sub-stream: each concern draws from its own seeded stream. */
122
+ function stream(seed: number, salt: string): Random {
123
+ return mulberry32((seed ^ hashText(salt)) >>> 0);
124
+ }
125
+
126
+ export function pickWeighted<T>(list: Weighted<T>, random: Random): T {
127
+ if (list.length === 0) throw new StyleError("empty weighted list");
128
+ let total = 0;
129
+ for (const [, weight] of list) total += weight > 0 ? weight : 0;
130
+ if (!(total > 0)) return list[0]![0];
131
+ let roll = random() * total;
132
+ for (const [value, weight] of list) {
133
+ if (!(weight > 0)) continue;
134
+ roll -= weight;
135
+ if (roll < 0) return value;
136
+ }
137
+ return list[list.length - 1]![0];
138
+ }
139
+
140
+ /** Standard normal, clamped to ±3 so humanisation has a hard bound. */
141
+ function gauss(random: Random): number {
142
+ const u = Math.max(1e-12, random());
143
+ const v = random();
144
+ const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
145
+ return Math.max(-3, Math.min(3, z));
146
+ }
147
+
148
+ const clamp = (value: number, low: number, high: number) =>
149
+ Math.min(high, Math.max(low, value));
150
+ const mod = (value: number, size: number) => ((value % size) + size) % size;
151
+ const between = (range: readonly [number, number], random: Random) =>
152
+ range[0] + (range[1] - range[0]) * random();
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // The plan
156
+
157
+ /** One playable scale tone: the key offset in the tuning and its pitch. */
158
+ export type ScaleTone = Readonly<{
159
+ /** Key offset above the root key within one period. */
160
+ key: number;
161
+ /** Extra note cents (12-TET quarter tones without a tuning). */
162
+ cents: number;
163
+ /** Sounding semitones above the tonic (fractional for quarter tones). */
164
+ semis: number;
165
+ }>;
166
+
167
+ export type ScaleModel = Readonly<{
168
+ /** Scale or tuning name. */
169
+ name: string;
170
+ /** Keys per period: 12, or the tuning's size (pelog 7). */
171
+ period: number;
172
+ tones: readonly ScaleTone[];
173
+ /** Sounding semitones per period (12 for octave tunings). */
174
+ periodSemis: number;
175
+ }>;
176
+
177
+ export type PlannedChord = Readonly<{
178
+ /** Start in bars (fractional when the harmonic rhythm is faster). */
179
+ bar: number;
180
+ bars: number;
181
+ /** Numeral as written in the grammar, or a degree label (`d0`). */
182
+ numeral: string;
183
+ /** Root as a scale index (modal, drone) or pitch class (functional). */
184
+ rootPc: number;
185
+ /** Pitch classes (12-TET) or scale indices (non-12 tunings). */
186
+ pcs: readonly number[];
187
+ /** True when `pcs` are scale indices. */
188
+ degrees: boolean;
189
+ }>;
190
+
191
+ export type PlannedSection = Readonly<{
192
+ kind: SectionKind;
193
+ name: string;
194
+ startBar: number;
195
+ bars: number;
196
+ energy: number;
197
+ roles: readonly RoleName[];
198
+ }>;
199
+
200
+ export type RoleTrack = Readonly<{
201
+ trackId: string;
202
+ roles: readonly RoleName[];
203
+ instrument: string;
204
+ /** Kit (drums) or the word picked. */
205
+ voice: RoleVoice;
206
+ }>;
207
+
208
+ export type StylePlan = Readonly<{
209
+ style: ResolvedStyle;
210
+ seed: number;
211
+ bars: number;
212
+ signature: string;
213
+ beatsPerBar: number;
214
+ beatUnit: number;
215
+ grouping: readonly number[] | undefined;
216
+ hypermeter: number;
217
+ bpm: number;
218
+ subdivision: number;
219
+ stepsPerBar: number;
220
+ stepTicks: number;
221
+ barTicks: number;
222
+ swing: number;
223
+ tonic: number;
224
+ rootKey: number;
225
+ scale: ScaleModel;
226
+ /** Library scale or mode name the key text uses (12-TET only). */
227
+ scaleName: string;
228
+ keyText: string | null;
229
+ tuning: Tuning | undefined;
230
+ harmonyModel: ResolvedStyle["harmony"]["model"];
231
+ /** Numeral source per phrase: preset name, `form`, `chain` or model. */
232
+ harmonySource: string;
233
+ chords: readonly PlannedChord[];
234
+ sections: readonly PlannedSection[];
235
+ tracks: readonly RoleTrack[];
236
+ /** Melody phrases: start/end bar and the notes' ids. */
237
+ phrases: readonly Readonly<{ startBar: number; bars: number }>[];
238
+ /** Role played by each note id. */
239
+ noteRoles: ReadonlyMap<string, RoleName>;
240
+ humanizeTicks: number;
241
+ }>;
242
+
243
+ export type GeneratedStyle = Readonly<{
244
+ plan: StylePlan;
245
+ data: TrackScoreData;
246
+ operations: readonly ScoreOperation[];
247
+ provenance: StyleProvenance;
248
+ }>;
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Meter, tempo, scale
252
+
253
+ export function parseSignature(text: string): [number, number] {
254
+ const match = /^(\d{1,2})\/(\d{1,2})$/.exec(text.trim());
255
+ if (!match) throw new StyleError(`bad time signature "${text}"`);
256
+ const top = Number(match[1]);
257
+ const bottom = Number(match[2]);
258
+ if (top < 1 || top > 32 || ![1, 2, 4, 8, 16, 32].includes(bottom))
259
+ throw new StyleError(`bad time signature "${text}"`);
260
+ return [top, bottom];
261
+ }
262
+
263
+ /** Steps of the role grids per bar for a meter and subdivision. */
264
+ export function stepsPerBarOf(
265
+ beatsPerBar: number,
266
+ beatUnit: number,
267
+ subdivision: number,
268
+ ): number {
269
+ return Math.max(1, Math.round(((beatsPerBar * 4) / beatUnit) * subdivision));
270
+ }
271
+
272
+ function scaleFor(
273
+ style: ResolvedStyle,
274
+ scaleName: string,
275
+ ): { model: ScaleModel; tuning?: Tuning; keyScale: string } {
276
+ const pitch = style.pitch;
277
+ const preset = pitch.tuning ? tuningPreset(pitch.tuning) : undefined;
278
+ if (pitch.tuning && !preset)
279
+ throw new StyleError(`${style.id}: unknown tuning "${pitch.tuning}"`);
280
+ if (preset && preset.cents.length !== 12) {
281
+ // A non-12 tuning: its degrees are the scale, one key per degree.
282
+ const size = preset.cents.length;
283
+ const period = preset.cents[size - 1]!;
284
+ const degrees = pitch.degrees ?? [...Array(size).keys()];
285
+ const tones = degrees.map((degree) => ({
286
+ key: degree,
287
+ cents: 0,
288
+ semis: degree === 0 ? 0 : preset.cents[degree - 1]! / 100,
289
+ }));
290
+ return {
291
+ model: {
292
+ name: preset.name,
293
+ period: size,
294
+ tones,
295
+ periodSemis: period / 100,
296
+ },
297
+ tuning: { name: preset.name },
298
+ keyScale: "major",
299
+ };
300
+ }
301
+ const steps = stepsOfScale(scaleName);
302
+ if (!steps) throw new StyleError(`${style.id}: unknown scale "${scaleName}"`);
303
+ if (preset) {
304
+ // A 12-key tuning (a maqam or raga table): each step sits on the key
305
+ // whose tuned pitch is nearest, with no note cents.
306
+ const table = [0, ...preset.cents.slice(0, 11)];
307
+ const tones = steps.map((step) => {
308
+ let best = 0;
309
+ for (let key = 1; key < 12; key += 1)
310
+ if (
311
+ Math.abs(table[key]! - step * 100) <
312
+ Math.abs(table[best]! - step * 100)
313
+ )
314
+ best = key;
315
+ return { key: best, cents: 0, semis: table[best]! / 100 };
316
+ });
317
+ return {
318
+ model: { name: scaleName, period: 12, tones, periodSemis: 12 },
319
+ tuning: { name: preset.name },
320
+ keyScale: scaleName,
321
+ };
322
+ }
323
+ const tones = steps.map((step) => {
324
+ const key = Math.floor(step + 1e-9);
325
+ return { key, cents: Math.round((step - key) * 100), semis: step };
326
+ });
327
+ return {
328
+ model: { name: scaleName, period: 12, tones, periodSemis: 12 },
329
+ keyScale: scaleName,
330
+ };
331
+ }
332
+
333
+ /** Semitone steps of a MODES or SCALES name. */
334
+ export function stepsOfScale(name: string): readonly number[] | undefined {
335
+ if (Object.prototype.hasOwnProperty.call(MODES, name))
336
+ return MODES[name as ModeName];
337
+ if (Object.prototype.hasOwnProperty.call(SCALES, name))
338
+ return SCALES[name as ScaleName].steps;
339
+ return undefined;
340
+ }
341
+
342
+ /** The seven-note mode the chord engine harmonises a scale with. */
343
+ export function modeOfScale(name: string): ModeName {
344
+ if (Object.prototype.hasOwnProperty.call(MODES, name))
345
+ return name as ModeName;
346
+ if (Object.prototype.hasOwnProperty.call(SCALES, name))
347
+ return SCALES[name as ScaleName].mode;
348
+ return "major";
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Harmony
353
+
354
+ const PLAIN_TRIAD =
355
+ /^[b#]?(?:VII|VI|IV|V|III|II|I|vii|vi|iv|v|iii|ii|i)[oø+]?$/;
356
+
357
+ const isMinorMode = (mode: ModeName) => MODES[mode][2] === 3;
358
+
359
+ /** Cadence tokens to numerals, with the tonic in the mode's quality. */
360
+ export function cadenceNumerals(cadence: string, mode: ModeName): string[] {
361
+ if (cadence === "half") return ["V"];
362
+ const minor = isMinorMode(mode);
363
+ return cadence
364
+ .split("-")
365
+ .map((token) =>
366
+ token === "I" || token === "i" ? (minor ? "i" : "I") : token,
367
+ );
368
+ }
369
+
370
+ /** The grammar's numeral vocabulary and edges, for the validator. */
371
+ export type HarmonyGrammar = Readonly<{
372
+ edges: ReadonlySet<string>;
373
+ numerals: ReadonlySet<string>;
374
+ }>;
375
+
376
+ export const stripSeventh = (numeral: string) =>
377
+ numeral.replace(/(maj7|7)$/, "");
378
+
379
+ export function harmonyGrammar(
380
+ style: ResolvedStyle,
381
+ mode: ModeName,
382
+ ): HarmonyGrammar {
383
+ const edges = new Set<string>();
384
+ const numerals = new Set<string>();
385
+ const cycle = (list: readonly string[]) => {
386
+ list.forEach((numeral, i) => {
387
+ const next = list[(i + 1) % list.length]!;
388
+ numerals.add(stripSeventh(numeral));
389
+ edges.add(`${stripSeventh(numeral)}>${stripSeventh(next)}`);
390
+ edges.add(`${stripSeventh(numeral)}>${stripSeventh(numeral)}`);
391
+ });
392
+ };
393
+ const harmony = style.harmony;
394
+ for (const [name] of harmony.presets ?? []) {
395
+ const preset = findPreset(name);
396
+ if (preset) cycle(preset.numerals);
397
+ }
398
+ for (const [form] of harmony.forms ?? []) cycle(form);
399
+ const chain = harmony.chain ?? {};
400
+ const chainTonic = isMinorMode(mode) ? "i" : "I";
401
+ const chainStart = chain[chainTonic] ? chainTonic : Object.keys(chain)[0];
402
+ for (const [from, nexts] of Object.entries(chain)) {
403
+ numerals.add(stripSeventh(from));
404
+ edges.add(`${stripSeventh(from)}>${stripSeventh(from)}`);
405
+ if (nexts.length === 0 && chainStart)
406
+ edges.add(`${stripSeventh(from)}>${stripSeventh(chainStart)}`);
407
+ for (const [to] of nexts) {
408
+ numerals.add(stripSeventh(to));
409
+ edges.add(`${stripSeventh(from)}>${stripSeventh(to)}`);
410
+ if (!chain[to]?.length && !chain[stripSeventh(to)]?.length && chainStart)
411
+ edges.add(`${stripSeventh(to)}>${stripSeventh(chainStart)}`);
412
+ }
413
+ }
414
+ const cadences = harmony.cadences.map(([cadence]) =>
415
+ cadenceNumerals(cadence, mode),
416
+ );
417
+ for (const list of cadences) {
418
+ for (const numeral of list) numerals.add(numeral);
419
+ for (let i = 0; i + 1 < list.length; i += 1)
420
+ edges.add(`${list[i]}>${list[i + 1]}`);
421
+ }
422
+ // Entering a cadence from anywhere and leaving it to the phrase start.
423
+ for (const list of cadences)
424
+ for (const numeral of numerals) {
425
+ edges.add(`${numeral}>${list[0]}`);
426
+ edges.add(`${list[list.length - 1]}>${numeral}`);
427
+ }
428
+ return { edges, numerals };
429
+ }
430
+
431
+ function functionalNumerals(
432
+ style: ResolvedStyle,
433
+ mode: ModeName,
434
+ slots: number,
435
+ phraseSlots: number,
436
+ random: Random,
437
+ ): { numerals: string[]; source: string } {
438
+ const harmony = style.harmony;
439
+ const sources: Weighted<string> = [
440
+ ...(harmony.presets?.length
441
+ ? [["presets", harmony.sources?.presets ?? 1] as const]
442
+ : []),
443
+ ...(harmony.forms?.length
444
+ ? [["forms", harmony.sources?.forms ?? 1] as const]
445
+ : []),
446
+ ...(harmony.chain && Object.keys(harmony.chain).length
447
+ ? [["chain", harmony.sources?.chain ?? 1] as const]
448
+ : []),
449
+ ];
450
+ if (sources.length === 0) throw new StyleError(`${style.id}: no numerals`);
451
+ const source = pickWeighted(sources, random);
452
+ let base: string[] = [];
453
+ let label: string = source;
454
+ if (source === "presets") {
455
+ const name = pickWeighted(harmony.presets!, random);
456
+ const preset = findPreset(name);
457
+ if (!preset) throw new StyleError(`${style.id}: unknown preset "${name}"`);
458
+ base = [...preset.numerals];
459
+ label = preset.name;
460
+ } else if (source === "forms") {
461
+ base = [...pickWeighted(harmony.forms!, random)];
462
+ label = "form";
463
+ }
464
+ const out: string[] = [];
465
+ if (source === "chain") {
466
+ const chain = harmony.chain!;
467
+ const tonic = isMinorMode(mode) ? "i" : "I";
468
+ const start = chain[tonic] ? tonic : Object.keys(chain)[0]!;
469
+ let current = start;
470
+ for (let i = 0; i < slots; i += 1) {
471
+ out.push(current);
472
+ const nexts = chain[current] ?? chain[stripSeventh(current)];
473
+ // A numeral with no successors returns to the chain's start.
474
+ current = nexts?.length ? pickWeighted(nexts, random) : start;
475
+ }
476
+ } else for (let i = 0; i < slots; i += 1) out.push(base[i % base.length]!);
477
+ // Fixed forms keep their changes; presets and chains cadence at each
478
+ // phrase end.
479
+ if (source !== "forms" && harmony.cadences.length > 0)
480
+ for (let end = phraseSlots; end <= slots; end += phraseSlots) {
481
+ const cadence = cadenceNumerals(
482
+ pickWeighted(harmony.cadences, random),
483
+ mode,
484
+ );
485
+ if (cadence.length > phraseSlots) continue;
486
+ cadence.forEach((numeral, i) => {
487
+ out[end - cadence.length + i] = numeral;
488
+ });
489
+ }
490
+ // Seventh colour on plain triads only (not on 7ths, 9ths, 6ths or
491
+ // secondary-dominant slashes).
492
+ return {
493
+ numerals: out.map((numeral) =>
494
+ !PLAIN_TRIAD.test(numeral) || random() >= style.harmony.sevenths
495
+ ? numeral
496
+ : `${numeral}7`,
497
+ ),
498
+ source: label,
499
+ };
500
+ }
501
+
502
+ function planChords(
503
+ style: ResolvedStyle,
504
+ scale: ScaleModel,
505
+ key: Key | undefined,
506
+ bars: number,
507
+ hypermeter: number,
508
+ random: Random,
509
+ ): { chords: PlannedChord[]; source: string } {
510
+ const harmony = style.harmony;
511
+ if (harmony.model === "none") return { chords: [], source: "none" };
512
+ const perBar = Math.max(0.125, pickWeighted(harmony.rhythm, random));
513
+ const slotBars = 1 / perBar;
514
+ const slots = Math.max(1, Math.ceil(bars / slotBars));
515
+ const phraseSlots = Math.max(1, Math.round(hypermeter / slotBars));
516
+ const twelve = scale.period === 12;
517
+ const make = (
518
+ i: number,
519
+ numeral: string,
520
+ root: number,
521
+ pcs: number[],
522
+ degrees: boolean,
523
+ ) => ({
524
+ bar: i * slotBars,
525
+ bars: Math.min(slotBars, bars - i * slotBars),
526
+ numeral,
527
+ rootPc: root,
528
+ pcs: Object.freeze(pcs),
529
+ degrees,
530
+ });
531
+ if (harmony.model === "functional" && twelve && key) {
532
+ const { numerals, source } = functionalNumerals(
533
+ style,
534
+ key.mode,
535
+ slots,
536
+ phraseSlots,
537
+ random,
538
+ );
539
+ const chords = numerals.map((numeral, i) => {
540
+ const chord = parseRoman({ tonic: key.tonic, mode: key.mode }, numeral);
541
+ if (!chord) throw new StyleError(`${style.id}: bad numeral "${numeral}"`);
542
+ return make(i, numeral, chord.root, chordPitchClasses(chord), false);
543
+ });
544
+ return { chords, source };
545
+ }
546
+ // Modal vamps and drones build on scale degrees, so they work in any
547
+ // tuning: a triad is degrees d, d+2, d+4 of the scale.
548
+ const size = scale.tones.length;
549
+ const triad = (d: number) =>
550
+ [d, d + 2, d + 4]
551
+ .map((x) => mod(x, size))
552
+ .filter((x, i, all) => all.indexOf(x) === i);
553
+ if (harmony.model === "drone") {
554
+ const fifth = nearestDegree(scale, 7);
555
+ const chords = Array.from({ length: slots }, (_, i) =>
556
+ make(i, "drone", 0, fifth === 0 ? [0] : [0, fifth], true),
557
+ );
558
+ return { chords, source: "drone" };
559
+ }
560
+ // modal: tonic alternating with a weighted neighbour (bVII, II, IV, v).
561
+ const neighbours: Weighted<number> = [
562
+ [size - 1, 3],
563
+ [1, 2],
564
+ [Math.min(3, size - 1), 2],
565
+ [Math.min(4, size - 1), 1],
566
+ ];
567
+ const chords: PlannedChord[] = [];
568
+ for (let i = 0; i < slots; i += 1) {
569
+ const degree = i % 2 === 0 ? 0 : pickWeighted(neighbours, random);
570
+ chords.push(
571
+ make(i, `d${degree}`, degree, size >= 5 ? triad(degree) : [degree], true),
572
+ );
573
+ }
574
+ return { chords, source: "modal" };
575
+ }
576
+
577
+ /** Scale index whose pitch is nearest `semis` above the tonic. */
578
+ function nearestDegree(scale: ScaleModel, semis: number): number {
579
+ let best = 0;
580
+ scale.tones.forEach((tone, i) => {
581
+ if (
582
+ Math.abs(tone.semis - semis) < Math.abs(scale.tones[best]!.semis - semis)
583
+ )
584
+ best = i;
585
+ });
586
+ return best;
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Pitch space: scale indices <-> keys
591
+
592
+ type Pitch = Readonly<{ key: number; cents: number; semis: number }>;
593
+
594
+ /** Absolute scale index (degree + octave × size) to a key. */
595
+ function toneAt(plan: PitchContext, index: number): Pitch {
596
+ const size = plan.scale.tones.length;
597
+ const octave = Math.floor(index / size);
598
+ const tone = plan.scale.tones[mod(index, size)]!;
599
+ return {
600
+ key: plan.rootKey + octave * plan.scale.period + tone.key,
601
+ cents: tone.cents,
602
+ semis: plan.rootKey + octave * plan.scale.periodSemis + tone.semis,
603
+ };
604
+ }
605
+
606
+ /** The scale index whose pitch is nearest a sounding MIDI pitch. */
607
+ function indexNear(plan: PitchContext, semis: number): number {
608
+ const size = plan.scale.tones.length;
609
+ const octave = Math.floor((semis - plan.rootKey) / plan.scale.periodSemis);
610
+ let best = octave * size;
611
+ let distance = Infinity;
612
+ for (let i = (octave - 1) * size; i <= (octave + 2) * size; i += 1) {
613
+ const d = Math.abs(toneAt(plan, i).semis - semis);
614
+ if (d < distance - 1e-9) {
615
+ distance = d;
616
+ best = i;
617
+ }
618
+ }
619
+ return best;
620
+ }
621
+
622
+ type PitchContext = Readonly<{ scale: ScaleModel; rootKey: number }>;
623
+
624
+ /** Keys of a chord near `anchor`, for chords made of scale indices. */
625
+ function degreeChordKeys(
626
+ plan: PitchContext,
627
+ chord: PlannedChord,
628
+ low: number,
629
+ high: number,
630
+ ): Pitch[] {
631
+ const base = indexNear(plan, (low + high) / 2 - 4);
632
+ const size = plan.scale.tones.length;
633
+ const out: Pitch[] = [];
634
+ const start = base - mod(base, size);
635
+ for (const degree of chord.pcs) {
636
+ let index = start + degree;
637
+ let pitch = toneAt(plan, index);
638
+ while (pitch.semis < low && index < start + 4 * size)
639
+ pitch = toneAt(plan, (index += size));
640
+ while (pitch.semis > high && index > start - 4 * size)
641
+ pitch = toneAt(plan, (index -= size));
642
+ out.push(pitch);
643
+ }
644
+ return out.sort((a, b) => a.semis - b.semis);
645
+ }
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // Tracks
649
+
650
+ function instrumentFields(voice: RoleVoice): Partial<TrackInput> & {
651
+ instrument: string;
652
+ } {
653
+ if (voice.instrument === "drums")
654
+ return { instrument: "drums", ...(voice.kit ? { kit: voice.kit } : {}) };
655
+ const meaning = resolveInstrumentWord(voice.instrument);
656
+ let fields: Partial<TrackInput> & { instrument: string };
657
+ if (
658
+ meaning &&
659
+ (meaning.field === "modal" ||
660
+ meaning.field === "wind" ||
661
+ meaning.field === "sing")
662
+ )
663
+ fields = instrumentPatchForWord(voice.instrument) as typeof fields;
664
+ else if (meaning?.field === "string" && meaning.preset)
665
+ fields = {
666
+ instrument: meaning.instrument,
667
+ string: { preset: meaning.preset } as TrackInput["string"],
668
+ };
669
+ else fields = { instrument: meaning?.instrument ?? voice.instrument };
670
+ const rig = voice.rig ?? meaning?.fx;
671
+ if (rig) {
672
+ const fx = applyRigPreset(undefined, rig);
673
+ const reverb = rigReverb(rig);
674
+ if (fx) fields = { ...fields, fx };
675
+ if (reverb)
676
+ fields = { ...fields, reverb: reverb as unknown as TrackInput["reverb"] };
677
+ }
678
+ return fields;
679
+ }
680
+
681
+ function roleFx(
682
+ style: ResolvedStyle,
683
+ roles: readonly RoleName[],
684
+ ): TrackFx | undefined {
685
+ const out: Record<string, unknown> = {};
686
+ for (const role of roles)
687
+ for (const [effect, preset] of Object.entries(style.mix.fx?.[role] ?? {})) {
688
+ const values = (
689
+ FX_PRESETS as Record<string, Record<string, unknown> | undefined>
690
+ )[effect]?.[preset];
691
+ if (!values)
692
+ throw new StyleError(
693
+ `${style.id}: unknown fx preset ${effect} ${preset}`,
694
+ );
695
+ out[effect] = { ...values };
696
+ }
697
+ return Object.keys(out).length ? (out as TrackFx) : undefined;
698
+ }
699
+
700
+ function planTracks(style: ResolvedStyle, random: Random): RoleTrack[] {
701
+ const roles = style.texture.roles;
702
+ const present = (role: RoleName) => {
703
+ const texture = roles[role];
704
+ if (!texture || texture.voices.length === 0) return false;
705
+ // Kit and perc roles need a grid (or the cycle); optional roles join
706
+ // most of the time.
707
+ if (
708
+ (KIT_ROLES as readonly string[]).includes(role) ||
709
+ (PERC_ROLES as readonly string[]).includes(role)
710
+ ) {
711
+ const hasGrid = (style.rhythm.onsets[role]?.length ?? 0) > 0;
712
+ const cycle = role === "perc" && style.meter.cycle !== undefined;
713
+ if (!hasGrid && !cycle) return false;
714
+ }
715
+ return texture.required || random() < 0.75;
716
+ };
717
+ const out: RoleTrack[] = [];
718
+ const kitRoles = KIT_ROLES.filter(present);
719
+ if (kitRoles.length > 0) {
720
+ const voice = pickWeighted(
721
+ roles[kitRoles[0]!]!.voices.map((v) => [v, v.weight] as const),
722
+ random,
723
+ );
724
+ out.push({ trackId: "drums", roles: kitRoles, instrument: "drums", voice });
725
+ }
726
+ for (const role of [...PERC_ROLES, ...PITCHED_ROLES] as RoleName[]) {
727
+ if (!present(role)) continue;
728
+ const voice = pickWeighted(
729
+ roles[role]!.voices.map((v) => [v, v.weight] as const),
730
+ random,
731
+ );
732
+ out.push({
733
+ trackId: role,
734
+ roles: [role],
735
+ instrument: voice.instrument,
736
+ voice,
737
+ });
738
+ }
739
+ return out;
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Form
744
+
745
+ function planSections(
746
+ style: ResolvedStyle,
747
+ bars: number,
748
+ hypermeter: number,
749
+ tracks: readonly RoleTrack[],
750
+ random: Random,
751
+ ): PlannedSection[] {
752
+ const plan = [...pickWeighted(style.form.plans, random)];
753
+ const unit = Math.max(1, Math.min(hypermeter, 4));
754
+ const fit = Math.max(1, Math.floor(bars / unit));
755
+ let kinds = plan;
756
+ if (kinds.length > fit) {
757
+ const core = plan.filter((kind) => kind !== "intro" && kind !== "outro");
758
+ kinds = (core.length ? core : plan).slice(0, fit);
759
+ }
760
+ const base = Math.floor(bars / kinds.length);
761
+ const allRoles = tracks.flatMap((track) => track.roles);
762
+ const seen = new Map<string, number>();
763
+ let start = 0;
764
+ return kinds.map((kind, i) => {
765
+ const length = i === kinds.length - 1 ? bars - start : base;
766
+ const count = (seen.get(kind) ?? 0) + 1;
767
+ seen.set(kind, count);
768
+ const mapped = style.form.roleMap?.[kind];
769
+ const section: PlannedSection = {
770
+ kind,
771
+ name: count === 1 ? kind : `${kind} ${count}`,
772
+ startBar: start,
773
+ bars: length,
774
+ energy: clamp(style.form.energy?.[kind] ?? 0.7, 0, 1),
775
+ roles: mapped
776
+ ? allRoles.filter((role) => mapped.includes(role))
777
+ : allRoles,
778
+ };
779
+ start += length;
780
+ return section;
781
+ });
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Generation
786
+
787
+ type Draft = {
788
+ trackId: string;
789
+ role: RoleName;
790
+ tick: number;
791
+ duration: number;
792
+ key: number;
793
+ cents: number;
794
+ velocity: number;
795
+ articulation?: string;
796
+ };
797
+
798
+ /** Generate a style by id (or an already resolved/blended style). */
799
+ export function generateStyle(
800
+ style: StyleId | ResolvedStyle,
801
+ options: StyleOptions = {},
802
+ ): GeneratedStyle {
803
+ const resolved = typeof style === "string" ? resolveKnown(style) : style;
804
+ const seed = Math.trunc(clamp(options.seed ?? 1, 0, STYLE_LIMITS.maxSeed));
805
+ const bars = Math.trunc(
806
+ clamp(
807
+ options.bars ?? STYLE_LIMITS.defaultBars,
808
+ STYLE_LIMITS.minBars,
809
+ STYLE_LIMITS.maxBars,
810
+ ),
811
+ );
812
+ if (
813
+ !Number.isFinite(options.seed ?? 1) ||
814
+ !Number.isFinite(options.bars ?? 8)
815
+ )
816
+ throw new StyleError("seed and bars must be numbers");
817
+ const root = (seed ^ hashText(resolved.id) ^ (resolved.seedSalt >>> 0)) >>> 0;
818
+ const rng = (salt: string) => stream(root, salt);
819
+ const plan = planStyle(resolved, seed, bars, options, rng);
820
+ const drafts = playRoles(plan, rng);
821
+ return assemble(plan, drafts);
822
+ }
823
+
824
+ function resolveKnown(id: StyleId): ResolvedStyle {
825
+ if (!STYLE_TREE.has(id)) throw new StyleError(`unknown style "${id}"`);
826
+ return resolveStyle(id);
827
+ }
828
+
829
+ function planStyle(
830
+ style: ResolvedStyle,
831
+ seed: number,
832
+ bars: number,
833
+ options: StyleOptions,
834
+ rng: (salt: string) => Random,
835
+ ): Omit<StylePlan, "noteRoles" | "phrases"> & { phrases: never[] } {
836
+ const random = rng("plan");
837
+ const signature = pickWeighted(style.meter.signatures, random);
838
+ const [beatsPerBar, beatUnit] = parseSignature(signature);
839
+ // Only groupings that fill the bar: 2+2+3 is a 7/8, never an 11/8.
840
+ const fitting = style.meter.grouping?.filter(
841
+ ([g]) => g.reduce((a, b) => a + b, 0) === beatsPerBar,
842
+ );
843
+ const grouping = fitting?.length ? pickWeighted(fitting, random) : undefined;
844
+ const hypermeter = Math.max(
845
+ 1,
846
+ Math.round(pickWeighted(style.meter.hypermeter, random)),
847
+ );
848
+ const [low, high] = style.tempo.bpm;
849
+ const typical = clamp(style.tempo.typical, low, high);
850
+ // A triangular draw around the typical tempo.
851
+ const u = random();
852
+ const lean = typical - low;
853
+ const span = high - low;
854
+ const drawn =
855
+ span <= 0
856
+ ? low
857
+ : u < lean / span
858
+ ? low + Math.sqrt(u * span * lean)
859
+ : high - Math.sqrt((1 - u) * span * (high - typical));
860
+ const bpm = Math.round(
861
+ clamp(
862
+ options.bpm !== undefined ? clamp(options.bpm, low, high) : drawn,
863
+ 20,
864
+ 300,
865
+ ),
866
+ );
867
+ const subdivision = Math.max(1, Math.round(style.groove.subdivision));
868
+ const stepsPerBar = stepsPerBarOf(beatsPerBar, beatUnit, subdivision);
869
+ const barTicks = (beatsPerBar * TPB * 4) / beatUnit;
870
+ const stepTicks = barTicks / stepsPerBar;
871
+ const swing = between(style.groove.swingRatio, random);
872
+ const tonic =
873
+ options.tonic !== undefined
874
+ ? mod(Math.round(options.tonic), 12)
875
+ : style.pitch.tonic === "any"
876
+ ? Math.floor(random() * 12)
877
+ : mod(Math.round(pickWeighted(style.pitch.tonic, random)), 12);
878
+ const scaleName = pickWeighted(style.pitch.scales, random);
879
+ const {
880
+ model: scale,
881
+ tuning: tuningName,
882
+ keyScale,
883
+ } = scaleFor(style, scaleName);
884
+ const rootKey = 60 + tonic;
885
+ const tuning: Tuning | undefined = tuningName
886
+ ? { ...tuningName, root: rootKey }
887
+ : undefined;
888
+ const twelve = scale.period === 12;
889
+ const keyText = twelve ? `${noteNameOf(tonic)} ${keyScale}` : null;
890
+ const parsed = keyText ? parseKey(keyText) : undefined;
891
+ if (keyText && !parsed)
892
+ throw new StyleError(`${style.id}: key "${keyText}" does not parse`);
893
+ const key: Key | undefined = parsed
894
+ ? { tonic: parsed.tonic, mode: modeOfScale(keyScale) }
895
+ : undefined;
896
+ const { chords, source } = planChords(
897
+ style,
898
+ scale,
899
+ key,
900
+ bars,
901
+ hypermeter,
902
+ rng("harmony"),
903
+ );
904
+ const tracks = planTracks(style, rng("texture"));
905
+ const sections = planSections(style, bars, hypermeter, tracks, rng("form"));
906
+ return {
907
+ style,
908
+ seed,
909
+ bars,
910
+ signature,
911
+ beatsPerBar,
912
+ beatUnit,
913
+ grouping,
914
+ hypermeter,
915
+ bpm,
916
+ subdivision,
917
+ stepsPerBar,
918
+ stepTicks,
919
+ barTicks,
920
+ swing,
921
+ tonic,
922
+ rootKey,
923
+ scale,
924
+ scaleName: keyScale,
925
+ keyText: parsed ? keyName(parsed) : null,
926
+ tuning,
927
+ harmonyModel:
928
+ style.harmony.model === "functional" && !twelve
929
+ ? "modal"
930
+ : style.harmony.model,
931
+ harmonySource: source,
932
+ chords,
933
+ sections,
934
+ tracks,
935
+ phrases: [],
936
+ humanizeTicks: (style.groove.humanize.timingMs * bpm * TPB) / 60_000,
937
+ };
938
+ }
939
+
940
+ const SHARPS = [
941
+ "C",
942
+ "C#",
943
+ "D",
944
+ "D#",
945
+ "E",
946
+ "F",
947
+ "F#",
948
+ "G",
949
+ "G#",
950
+ "A",
951
+ "A#",
952
+ "B",
953
+ ];
954
+ const noteNameOf = (pc: number) => SHARPS[mod(pc, 12)]!;
955
+
956
+ /** Tick of grid step `step` (may exceed one bar) with swing applied. */
957
+ export function swungStepTick(
958
+ plan: Pick<StylePlan, "stepTicks" | "subdivision" | "swing">,
959
+ step: number,
960
+ ): number {
961
+ const base = step * plan.stepTicks;
962
+ if (plan.subdivision % 2 !== 0 || plan.swing === 1 || step % 2 === 0)
963
+ return base;
964
+ const shift = ((2 * plan.swing) / (1 + plan.swing) - 1) * plan.stepTicks;
965
+ return base + shift;
966
+ }
967
+
968
+ /** Ticks a role's onsets may sit off the swung grid (microtiming, bias, humanise). */
969
+ export function onsetTolerance(plan: StylePlan, role: RoleName): number {
970
+ const micro = Math.max(
971
+ 0,
972
+ ...(plan.style.groove.microtiming ?? [0]).map(Math.abs),
973
+ );
974
+ const bias = Math.abs(plan.style.groove.roleOffset?.[role] ?? 0);
975
+ return (micro + bias) * plan.stepTicks + 3 * plan.humanizeTicks + 1;
976
+ }
977
+
978
+ function sectionAt(
979
+ plan: StylePlan | ReturnType<typeof planStyle>,
980
+ bar: number,
981
+ ): PlannedSection {
982
+ const sections = plan.sections;
983
+ for (let i = sections.length - 1; i >= 0; i -= 1)
984
+ if (bar >= sections[i]!.startBar) return sections[i]!;
985
+ return sections[0]!;
986
+ }
987
+
988
+ function chordAt(
989
+ chords: readonly PlannedChord[],
990
+ bar: number,
991
+ ): PlannedChord | undefined {
992
+ for (let i = chords.length - 1; i >= 0; i -= 1)
993
+ if (bar + 1e-9 >= chords[i]!.bar) return chords[i];
994
+ return chords[0];
995
+ }
996
+
997
+ type Plan = ReturnType<typeof planStyle>;
998
+
999
+ function playRoles(plan: Plan, rng: (salt: string) => Random): Draft[] {
1000
+ const drafts: Draft[] = [];
1001
+ const style = plan.style;
1002
+ const kitOnsets = new Map<RoleName, Set<number>>();
1003
+ const groove = style.groove;
1004
+ const velocityAt = (step: number, energy: number, role: RoleName) => {
1005
+ const sub = plan.subdivision;
1006
+ const shape = groove.velocity ?? [1];
1007
+ const inBeat = step % sub;
1008
+ let accent =
1009
+ shape[Math.round((inBeat * shape.length) / sub) % shape.length] ?? 1;
1010
+ if (plan.grouping) {
1011
+ const stepsPerUnit = plan.stepsPerBar / plan.beatsPerBar;
1012
+ let at = 0;
1013
+ const starts = new Set<number>();
1014
+ for (const group of plan.grouping) {
1015
+ starts.add(Math.round(at * stepsPerUnit));
1016
+ at += group;
1017
+ }
1018
+ const barStep = step % plan.stepsPerBar;
1019
+ accent = starts.has(barStep)
1020
+ ? Math.max(accent, 1)
1021
+ : Math.min(accent, 0.8);
1022
+ }
1023
+ const [lo, hi] = style.expression.dynamics;
1024
+ const base = lo + (hi - lo) * energy;
1025
+ const roleGain = role === "kick" || role === "snare" ? 1 : 0.92;
1026
+ return clamp(base * accent * roleGain, 0.05, 1);
1027
+ };
1028
+ const timeOf = (role: RoleName, step: number, random: Random) => {
1029
+ const sub = plan.subdivision;
1030
+ const micro = groove.microtiming ?? [];
1031
+ const offset =
1032
+ (micro.length
1033
+ ? micro[Math.round(((step % sub) * micro.length) / sub) % micro.length]!
1034
+ : 0) + (groove.roleOffset?.[role] ?? 0);
1035
+ return (
1036
+ swungStepTick(plan, step) +
1037
+ offset * plan.stepTicks +
1038
+ gauss(random) * plan.humanizeTicks
1039
+ );
1040
+ };
1041
+ const humanVelocity = (velocity: number, random: Random) =>
1042
+ clamp(velocity * (1 + gauss(random) * groove.humanize.velocity), 0.05, 1);
1043
+
1044
+ // --- Kit and percussion grids
1045
+ const totalSteps = plan.bars * plan.stepsPerBar;
1046
+ const gridOf = (role: RoleName) => {
1047
+ return roleGrid(style.rhythm.onsets[role], role, plan);
1048
+ };
1049
+ const decide = (role: RoleName, random: Random) => {
1050
+ const grid = gridOf(role);
1051
+ const hits = new Set<number>();
1052
+ if (!grid) return hits;
1053
+ for (let step = 0; step < totalSteps; step += 1) {
1054
+ const p = grid[step % plan.stepsPerBar]!;
1055
+ const section = sectionAt(plan, Math.floor(step / plan.stepsPerBar));
1056
+ if (!section.roles.includes(role)) continue;
1057
+ const scaled = p >= 1 ? 1 : p * (0.5 + section.energy * 0.7);
1058
+ if (p >= 1 || random() < scaled) hits.add(step);
1059
+ }
1060
+ return hits;
1061
+ };
1062
+ const kitTrack = plan.tracks.find((track) => track.trackId === "drums");
1063
+ const rhythmRandom = rng("rhythm");
1064
+ for (const role of [...KIT_ROLES, ...PERC_ROLES] as RoleName[]) {
1065
+ const owner = plan.tracks.find((track) => track.roles.includes(role));
1066
+ if (!owner) continue;
1067
+ if (role === "perc" && style.meter.cycle) continue;
1068
+ kitOnsets.set(role, decide(role, rhythmRandom));
1069
+ }
1070
+ // Fills: toms and snare in the last beat of each `every`-bar phrase.
1071
+ const fills = style.rhythm.fills;
1072
+ if (fills && kitTrack) {
1073
+ const random = rng("fills");
1074
+ const beatSteps = Math.max(
1075
+ 1,
1076
+ Math.round(plan.stepsPerBar / plan.beatsPerBar),
1077
+ );
1078
+ for (let bar = fills.every - 1; bar < plan.bars; bar += fills.every) {
1079
+ const density = between(fills.density, random);
1080
+ const start = (bar + 1) * plan.stepsPerBar - beatSteps;
1081
+ for (let step = start; step < (bar + 1) * plan.stepsPerBar; step += 1) {
1082
+ if (random() >= density) continue;
1083
+ const role: RoleName =
1084
+ kitTrack.roles.includes("tom") && random() < 0.5 ? "tom" : "snare";
1085
+ if (!kitTrack.roles.includes(role)) continue;
1086
+ kitOnsets.get(role)?.add(step);
1087
+ }
1088
+ }
1089
+ }
1090
+ // Locks: b with a (b only where a), b avoid a.
1091
+ for (const lock of style.rhythm.locks ?? []) {
1092
+ const a = kitOnsets.get(lock.a);
1093
+ const b = kitOnsets.get(lock.b);
1094
+ if (!a || !b) continue;
1095
+ for (const step of [...b])
1096
+ if ((lock.kind === "with") !== a.has(step)) b.delete(step);
1097
+ }
1098
+ const timingRandom = rng("timing");
1099
+ for (const [role, steps] of kitOnsets) {
1100
+ const owner = plan.tracks.find((track) => track.roles.includes(role))!;
1101
+ const onKit = owner.instrument === "drums";
1102
+ for (const step of [...steps].sort((x, y) => x - y)) {
1103
+ const bar = Math.floor(step / plan.stepsPerBar);
1104
+ const section = sectionAt(plan, bar);
1105
+ const key = onKit
1106
+ ? (KIT_PITCH[role] ?? PERC_ON_KIT[role] ?? KIT_PITCH.rim!)
1107
+ : role === "bell"
1108
+ ? plan.rootKey + 12
1109
+ : plan.rootKey;
1110
+ drafts.push({
1111
+ trackId: owner.trackId,
1112
+ role,
1113
+ tick: timeOf(role, step, timingRandom),
1114
+ duration: Math.max(30, plan.stepTicks * 0.9),
1115
+ key,
1116
+ cents: 0,
1117
+ velocity: humanVelocity(
1118
+ velocityAt(step, section.energy, role),
1119
+ timingRandom,
1120
+ ),
1121
+ });
1122
+ }
1123
+ }
1124
+ // Cycle (tala, iqa', gongan) on the perc track: one stroke per pulse.
1125
+ const cycle = style.meter.cycle;
1126
+ const percTrack = plan.tracks.find((track) => track.roles.includes("perc"));
1127
+ if (cycle && percTrack)
1128
+ drafts.push(...playCycle(plan, cycle, percTrack, timingRandom));
1129
+
1130
+ // --- Pitched roles
1131
+ const has = (role: RoleName) =>
1132
+ plan.tracks.some((track) => track.roles.includes(role));
1133
+ const kick = kitOnsets.get("kick") ?? new Set<number>();
1134
+ if (has("chords")) drafts.push(...playChords(plan, "chords", rng("chords")));
1135
+ if (has("pad")) drafts.push(...playSustain(plan, "pad"));
1136
+ if (has("drone")) drafts.push(...playDrone(plan));
1137
+ if (has("arp")) drafts.push(...playArp(plan, rng("arp")));
1138
+ if (has("bass"))
1139
+ drafts.push(...playBass(plan, kick, rng("bass"), timeOf, velocityAt));
1140
+ const lead = has("lead")
1141
+ ? playMelody(plan, "lead", rng("melody"))
1142
+ : { drafts: [], phrases: [] };
1143
+ drafts.push(...lead.drafts);
1144
+ if (has("counter"))
1145
+ drafts.push(...playMelody(plan, "counter", rng("counter")).drafts);
1146
+ (plan as { phrases: unknown }).phrases = lead.phrases;
1147
+ if (style.melody.row && plan.scale.period === 12)
1148
+ serialize(plan, drafts, style.melody.row, rng("row"));
1149
+ // Articulations per role.
1150
+ const articulationRandom = rng("articulation");
1151
+ for (const draft of drafts) {
1152
+ const weights = style.expression.articulation?.[draft.role];
1153
+ if (weights?.length)
1154
+ draft.articulation = pickWeighted(weights, articulationRandom);
1155
+ }
1156
+ return drafts;
1157
+ }
1158
+
1159
+ const SERIAL_ROLES = new Set<RoleName>([
1160
+ "bass",
1161
+ "chords",
1162
+ "pad",
1163
+ "arp",
1164
+ "drone",
1165
+ "lead",
1166
+ "counter",
1167
+ ]);
1168
+
1169
+ /**
1170
+ * Twelve-tone order: re-spell every pitched draft, in time order, with the
1171
+ * next pitch class of a seeded row. Each aggregate takes one form of the
1172
+ * row (prime, inversion, retrograde, retrograde inversion) at a seeded
1173
+ * transposition; the first is the prime. A note keeps its register: it
1174
+ * moves to the nearest key of its row pitch class, and the lead stays in
1175
+ * range and within an octave of its last note. Simultaneities (chords)
1176
+ * take consecutive row segments, so verticals are row hexachords and
1177
+ * trichords as in Schoenberg's and Webern's practice. "integral" also
1178
+ * serializes durations (1 to 12 units of a sixteenth's half) and dynamics
1179
+ * (twelve levels, ppp to fff), Boulez's Structures Ia scheme.
1180
+ */
1181
+ function serialize(
1182
+ plan: Plan,
1183
+ drafts: Draft[],
1184
+ kind: "pitch" | "integral",
1185
+ random: Random,
1186
+ ): void {
1187
+ const row = [...Array(12).keys()];
1188
+ for (let i = 11; i > 0; i -= 1) {
1189
+ const j = Math.floor(random() * (i + 1));
1190
+ [row[i], row[j]] = [row[j]!, row[i]!];
1191
+ }
1192
+ const form = (index: number): number[] => {
1193
+ if (index === 0) return row;
1194
+ const shape = Math.floor(random() * 4);
1195
+ const shift = Math.floor(random() * 12);
1196
+ const base = shape % 2 === 1 ? row.map((pc) => mod(-pc, 12)) : row;
1197
+ const ordered = shape >= 2 ? [...base].reverse() : base;
1198
+ return ordered.map((pc) => mod(pc + shift, 12));
1199
+ };
1200
+ const pitched = drafts
1201
+ .filter((draft) => SERIAL_ROLES.has(draft.role))
1202
+ .sort(
1203
+ (a, b) =>
1204
+ a.tick - b.tick || a.role.localeCompare(b.role) || a.key - b.key,
1205
+ );
1206
+ const [low, high] = plan.style.melody.range;
1207
+ const [dynLow, dynHigh] = plan.style.expression.dynamics;
1208
+ const last = new Map<RoleName, number>();
1209
+ let forms = 0;
1210
+ let current = form(forms);
1211
+ let at = 0;
1212
+ for (const draft of pitched) {
1213
+ if (at === 12) {
1214
+ forms += 1;
1215
+ current = form(forms);
1216
+ at = 0;
1217
+ }
1218
+ const order = at;
1219
+ const target = mod(plan.rootKey + current[at]!, 12);
1220
+ at += 1;
1221
+ let key = draft.key - mod(draft.key - target + 6, 12) + 6;
1222
+ if (draft.role === "lead" || draft.role === "counter") {
1223
+ while (key < low) key += 12;
1224
+ while (key > high) key -= 12;
1225
+ const previous = last.get(draft.role);
1226
+ if (previous !== undefined && Math.abs(key - previous) > 12)
1227
+ key += key > previous ? -12 : 12;
1228
+ last.set(draft.role, key);
1229
+ }
1230
+ draft.key = key;
1231
+ draft.cents = 0;
1232
+ if (kind === "integral") {
1233
+ // Order position to duration and dynamic: the series runs in step.
1234
+ const unit = plan.stepTicks / 2;
1235
+ draft.duration = Math.max(30, unit * (1 + ((order * 5) % 12)));
1236
+ draft.velocity = clamp(
1237
+ dynLow + ((dynHigh - dynLow) * ((order * 7) % 12)) / 11,
1238
+ 0.05,
1239
+ 1,
1240
+ );
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ function playCycle(
1246
+ plan: Plan,
1247
+ cycle: CycleSpec,
1248
+ track: RoleTrack,
1249
+ random: Random,
1250
+ ): Draft[] {
1251
+ const out: Draft[] = [];
1252
+ const pulse = (TPB * 4) / plan.beatUnit;
1253
+ const total = Math.floor((plan.bars * plan.barTicks) / pulse);
1254
+ const low = new Set(cycle.low ?? LOW_STROKES);
1255
+ const stress = new Set(cycle.stress);
1256
+ const release = new Set(cycle.release ?? []);
1257
+ const onKit = track.instrument === "drums";
1258
+ for (let i = 0; i < total; i += 1) {
1259
+ const at = i % cycle.beats;
1260
+ const stroke = cycle.strokes[at]!;
1261
+ if (stroke === ".") continue;
1262
+ const section = sectionAt(plan, Math.floor((i * pulse) / plan.barTicks));
1263
+ if (!section.roles.includes("perc")) continue;
1264
+ const isLow = low.has(stroke) && !release.has(at + 1);
1265
+ const accent = stress.has(at + 1) ? 1 : release.has(at + 1) ? 0.6 : 0.8;
1266
+ const [lo, hi] = plan.style.expression.dynamics;
1267
+ out.push({
1268
+ trackId: track.trackId,
1269
+ role: "perc",
1270
+ tick: i * pulse + gauss(random) * plan.humanizeTicks,
1271
+ duration: Math.max(30, pulse * 0.9),
1272
+ key: onKit
1273
+ ? isLow
1274
+ ? KIT_PITCH.tom!
1275
+ : KIT_PITCH.rim!
1276
+ : isLow
1277
+ ? plan.rootKey - 12
1278
+ : plan.rootKey,
1279
+ cents: 0,
1280
+ velocity: clamp((lo + (hi - lo) * section.energy) * accent, 0.05, 1),
1281
+ });
1282
+ }
1283
+ return out;
1284
+ }
1285
+
1286
+ function chordPitches(
1287
+ plan: Plan,
1288
+ chord: PlannedChord,
1289
+ role: RoleName,
1290
+ previous: number[],
1291
+ ): Pitch[] {
1292
+ const voicing = plan.style.harmony.voicing;
1293
+ const [low, high] = voicing.range.map(Math.round) as [number, number];
1294
+ const maxNotes = Math.max(1, Math.round(voicing.notes[1]));
1295
+ if (chord.degrees)
1296
+ return degreeChordKeys(plan, chord, low, high).slice(0, maxNotes);
1297
+ return [];
1298
+ void role;
1299
+ void previous;
1300
+ }
1301
+
1302
+ function voiceFunctional(
1303
+ plan: Plan,
1304
+ chord: PlannedChord,
1305
+ type: string,
1306
+ previous: number[] | undefined,
1307
+ ): number[] {
1308
+ const voicing = plan.style.harmony.voicing;
1309
+ const low = Math.round(voicing.range[0]);
1310
+ const high = Math.round(voicing.range[1]);
1311
+ const maxNotes = Math.max(1, Math.round(voicing.notes[1]));
1312
+ const minNotes = Math.max(
1313
+ 1,
1314
+ Math.min(maxNotes, Math.round(voicing.notes[0])),
1315
+ );
1316
+ const root = chord.rootPc;
1317
+ const anchor = Math.round((low + high) / 2) - 5;
1318
+ const rootKey = anchor + mod(root - anchor, 12);
1319
+ let notes: number[];
1320
+ if (type === "power") notes = [rootKey, rootKey + 7, rootKey + 12];
1321
+ else if (type === "shell") {
1322
+ const third = chord.pcs.find((pc) => [3, 4].includes(mod(pc - root, 12)));
1323
+ const seventh = chord.pcs.find((pc) =>
1324
+ [10, 11].includes(mod(pc - root, 12)),
1325
+ );
1326
+ notes = [rootKey];
1327
+ if (third !== undefined) notes.push(rootKey + mod(third - root, 12));
1328
+ if (seventh !== undefined) notes.push(rootKey + mod(seventh - root, 12));
1329
+ else notes.push(rootKey + 7);
1330
+ } else if (type === "quartal") {
1331
+ // Stack fourths from the root and keep the chord's own tones nearest.
1332
+ notes = [rootKey, rootKey + 5, rootKey + 10, rootKey + 15].map((key) => {
1333
+ const pc = mod(key, 12);
1334
+ if (chord.pcs.includes(pc)) return key;
1335
+ let best = key;
1336
+ for (const d of [1, -1, 2, -2])
1337
+ if (chord.pcs.includes(mod(key + d, 12))) {
1338
+ best = key + d;
1339
+ break;
1340
+ }
1341
+ return best;
1342
+ });
1343
+ } else {
1344
+ const spread: Spread = type === "open" || type === "wide" ? type : "close";
1345
+ const parsed = { root, quality: "maj" as const, extensions: [] };
1346
+ void parsed;
1347
+ notes = voiceChord(chordFromPcs(chord), {
1348
+ anchor,
1349
+ spread,
1350
+ low,
1351
+ high,
1352
+ ...(previous?.length ? { previous } : {}),
1353
+ });
1354
+ }
1355
+ notes = [...new Set(notes)].sort((a, b) => a - b);
1356
+ while (notes.length > maxNotes) notes.splice(1, 1); // drop the inner voices first
1357
+ while (notes.length < minNotes)
1358
+ notes.push(
1359
+ notes[notes.length - minNotes] !== undefined
1360
+ ? notes[0]! + 12
1361
+ : notes[0]! + 12,
1362
+ );
1363
+ // Fold into range by octaves.
1364
+ return [
1365
+ ...new Set(
1366
+ notes.map((key) => {
1367
+ let k = key;
1368
+ while (k < low) k += 12;
1369
+ while (k > high) k -= 12;
1370
+ return k;
1371
+ }),
1372
+ ),
1373
+ ].sort((a, b) => a - b);
1374
+ }
1375
+
1376
+ function chordFromPcs(chord: PlannedChord) {
1377
+ // Rebuild a chord-engine chord from its numeral, which the plan keeps.
1378
+ return (
1379
+ parsedChords.get(chord) ?? {
1380
+ root: chord.rootPc,
1381
+ quality: "maj" as const,
1382
+ extensions: [],
1383
+ }
1384
+ );
1385
+ }
1386
+ const parsedChords = new WeakMap<
1387
+ PlannedChord,
1388
+ ReturnType<typeof parseRoman> & object
1389
+ >();
1390
+
1391
+ function playChords(plan: Plan, role: RoleName, random: Random): Draft[] {
1392
+ const out: Draft[] = [];
1393
+ const style = plan.style;
1394
+ const grid = style.rhythm.onsets[role];
1395
+ const type = pickWeighted(style.harmony.voicing.types, random);
1396
+ const strum = style.harmony.voicing.strokes?.length ? 14 : 0;
1397
+ let previous: number[] | undefined;
1398
+ const key = plan.keyText ? parseKey(plan.keyText) : undefined;
1399
+ for (const chord of plan.chords) {
1400
+ if (!chord.degrees && key) {
1401
+ const parsed = parseRoman(
1402
+ { tonic: key.tonic, mode: modeOfScale(plan.scaleName) },
1403
+ chord.numeral,
1404
+ );
1405
+ if (parsed) parsedChords.set(chord, parsed);
1406
+ }
1407
+ const pitches: Pitch[] = chord.degrees
1408
+ ? chordPitches(plan, chord, role, [])
1409
+ : voiceFunctional(plan, chord, type, previous).map((k) => ({
1410
+ key: k,
1411
+ cents: 0,
1412
+ semis: k,
1413
+ }));
1414
+ previous = pitches.map((p) => p.key);
1415
+ const startTick = chord.bar * plan.barTicks;
1416
+ const endTick = (chord.bar + chord.bars) * plan.barTicks;
1417
+ const hits: number[] = [];
1418
+ if (grid && grid.length) {
1419
+ const stretched = stretchGrid(grid, plan.stepsPerBar);
1420
+ const firstStep = Math.round(startTick / plan.stepTicks);
1421
+ const lastStep = Math.round(endTick / plan.stepTicks);
1422
+ for (let step = firstStep; step < lastStep; step += 1) {
1423
+ const p = stretched[step % plan.stepsPerBar]!;
1424
+ if (p >= 1 || (p > 0 && random() < p))
1425
+ hits.push(swungStepTick(plan, step));
1426
+ }
1427
+ }
1428
+ if (hits.length === 0) hits.push(startTick);
1429
+ hits.forEach((tick, i) => {
1430
+ const section = sectionAt(plan, Math.floor(tick / plan.barTicks));
1431
+ if (!section.roles.includes(role)) return;
1432
+ const next = hits[i + 1] ?? endTick;
1433
+ const [lo, hi] = style.expression.dynamics;
1434
+ const velocity = clamp((lo + (hi - lo) * section.energy) * 0.85, 0.05, 1);
1435
+ pitches.forEach((pitch, v) => {
1436
+ out.push({
1437
+ trackId: role,
1438
+ role,
1439
+ tick: tick + v * strum,
1440
+ duration: Math.max(30, (next - tick) * 0.92 - v * strum),
1441
+ key: pitch.key,
1442
+ cents: pitch.cents,
1443
+ velocity,
1444
+ });
1445
+ });
1446
+ });
1447
+ }
1448
+ return out;
1449
+ }
1450
+
1451
+ function playSustain(plan: Plan, role: RoleName): Draft[] {
1452
+ const out: Draft[] = [];
1453
+ const key = plan.keyText ? parseKey(plan.keyText) : undefined;
1454
+ let previous: number[] | undefined;
1455
+ for (const chord of plan.chords) {
1456
+ const section = sectionAt(plan, Math.floor(chord.bar));
1457
+ if (!section.roles.includes(role)) continue;
1458
+ if (!chord.degrees && key && !parsedChords.has(chord)) {
1459
+ const parsed = parseRoman(
1460
+ { tonic: key.tonic, mode: modeOfScale(plan.scaleName) },
1461
+ chord.numeral,
1462
+ );
1463
+ if (parsed) parsedChords.set(chord, parsed);
1464
+ }
1465
+ const pitches: Pitch[] = chord.degrees
1466
+ ? chordPitches(plan, chord, role, [])
1467
+ : voiceFunctional(plan, chord, "open", previous).map((k) => ({
1468
+ key: k,
1469
+ cents: 0,
1470
+ semis: k,
1471
+ }));
1472
+ previous = pitches.map((p) => p.key);
1473
+ const [lo, hi] = plan.style.expression.dynamics;
1474
+ for (const pitch of pitches)
1475
+ out.push({
1476
+ trackId: role,
1477
+ role,
1478
+ tick: chord.bar * plan.barTicks,
1479
+ duration: Math.max(30, chord.bars * plan.barTicks - 20),
1480
+ key: pitch.key,
1481
+ cents: pitch.cents,
1482
+ velocity: clamp((lo + (hi - lo) * section.energy) * 0.6, 0.05, 1),
1483
+ });
1484
+ }
1485
+ return out;
1486
+ }
1487
+
1488
+ function playDrone(plan: Plan): Draft[] {
1489
+ const out: Draft[] = [];
1490
+ const tonic = toneAt(plan, indexNear(plan, plan.rootKey - 12));
1491
+ const fifthIndex = nearestDegree(plan.scale, 7);
1492
+ const fifth = toneAt(plan, indexNear(plan, plan.rootKey - 12) + fifthIndex);
1493
+ const [lo, hi] = plan.style.expression.dynamics;
1494
+ for (let bar = 0; bar < plan.bars; bar += 1) {
1495
+ const section = sectionAt(plan, bar);
1496
+ if (!section.roles.includes("drone")) continue;
1497
+ for (const pitch of fifthIndex === 0 ? [tonic] : [tonic, fifth])
1498
+ out.push({
1499
+ trackId: "drone",
1500
+ role: "drone",
1501
+ tick: bar * plan.barTicks,
1502
+ duration: plan.barTicks - 10,
1503
+ key: pitch.key,
1504
+ cents: pitch.cents,
1505
+ velocity: clamp((lo + (hi - lo) * section.energy) * 0.5, 0.05, 1),
1506
+ });
1507
+ }
1508
+ return out;
1509
+ }
1510
+
1511
+ function chordIndices(plan: Plan, chord: PlannedChord): number[] {
1512
+ if (chord.degrees) return [...chord.pcs];
1513
+ // Functional chord tones that are scale tones, as scale indices; the
1514
+ // rest are chromatic and played by pitch below.
1515
+ const out: number[] = [];
1516
+ plan.scale.tones.forEach((tone, i) => {
1517
+ if (tone.cents === 0 && chord.pcs.includes(mod(plan.tonic + tone.key, 12)))
1518
+ out.push(i);
1519
+ });
1520
+ return out;
1521
+ }
1522
+
1523
+ function playArp(plan: Plan, random: Random): Draft[] {
1524
+ const out: Draft[] = [];
1525
+ const grid = plan.style.rhythm.onsets.arp;
1526
+ const stretched = grid?.length
1527
+ ? stretchGrid(grid, plan.stepsPerBar)
1528
+ : undefined;
1529
+ const [lo, hi] = plan.style.expression.dynamics;
1530
+ const totalSteps = plan.bars * plan.stepsPerBar;
1531
+ let n = 0;
1532
+ for (let step = 0; step < totalSteps; step += 1) {
1533
+ const p = stretched
1534
+ ? stretched[step % plan.stepsPerBar]!
1535
+ : step % 2 === 0
1536
+ ? 1
1537
+ : 0;
1538
+ if (!(p >= 1 || (p > 0 && random() < p))) continue;
1539
+ const bar = step / plan.stepsPerBar;
1540
+ const section = sectionAt(plan, Math.floor(bar));
1541
+ if (!section.roles.includes("arp")) continue;
1542
+ const chord = chordAt(plan.chords, bar);
1543
+ const pitches = chord
1544
+ ? arpPitches(plan, chord)
1545
+ : [toneAt(plan, indexNear(plan, plan.rootKey + 12))];
1546
+ if (pitches.length === 0) continue;
1547
+ const pitch = pitches[n % pitches.length]!;
1548
+ n += 1;
1549
+ out.push({
1550
+ trackId: "arp",
1551
+ role: "arp",
1552
+ tick: swungStepTick(plan, step),
1553
+ duration: Math.max(30, plan.stepTicks * 0.8),
1554
+ key: pitch.key,
1555
+ cents: pitch.cents,
1556
+ velocity: clamp((lo + (hi - lo) * section.energy) * 0.7, 0.05, 1),
1557
+ });
1558
+ }
1559
+ return out;
1560
+ }
1561
+
1562
+ function arpPitches(plan: Plan, chord: PlannedChord): Pitch[] {
1563
+ const base = plan.rootKey + 12;
1564
+ if (!chord.degrees) {
1565
+ const root = base + mod(chord.rootPc - base, 12);
1566
+ return chord.pcs
1567
+ .map((pc) => root + mod(pc - chord.rootPc, 12))
1568
+ .map((k) => ({ key: k, cents: 0, semis: k }));
1569
+ }
1570
+ const start = indexNear(plan, base);
1571
+ const size = plan.scale.tones.length;
1572
+ return chord.pcs.map((degree) =>
1573
+ toneAt(plan, start - mod(start, size) + degree),
1574
+ );
1575
+ }
1576
+
1577
+ function playBass(
1578
+ plan: Plan,
1579
+ kick: ReadonlySet<number>,
1580
+ random: Random,
1581
+ timeOf: (role: RoleName, step: number, random: Random) => number,
1582
+ velocityAt: (step: number, energy: number, role: RoleName) => number,
1583
+ ): Draft[] {
1584
+ const style = plan.style;
1585
+ const behaviour = pickWeighted(style.bass.behaviour, random);
1586
+ if (behaviour === "none") return [];
1587
+ const [low, high] = style.bass.range.map(Math.round) as [number, number];
1588
+ const totalSteps = plan.bars * plan.stepsPerBar;
1589
+ const beatSteps = Math.max(
1590
+ 1,
1591
+ Math.round(plan.stepsPerBar / plan.beatsPerBar),
1592
+ );
1593
+ // Onsets: walking plays every beat; others follow the grid.
1594
+ let steps: number[] = [];
1595
+ if (behaviour === "walking") {
1596
+ for (let step = 0; step < totalSteps; step += beatSteps) steps.push(step);
1597
+ } else {
1598
+ const grid = style.bass.onsets?.length
1599
+ ? stretchGrid(style.bass.onsets, plan.stepsPerBar)
1600
+ : stretchGrid([1, 0, 0, 0, 1, 0, 0, 0], plan.stepsPerBar);
1601
+ for (let step = 0; step < totalSteps; step += 1) {
1602
+ const p = grid[step % plan.stepsPerBar]!;
1603
+ if (p >= 1 || (p > 0 && random() < p)) steps.push(step);
1604
+ }
1605
+ // Kick lock: drop off-kick onsets (latest first) until the share holds.
1606
+ const lock = style.bass.kickLock;
1607
+ if (lock && kick.size > 0) {
1608
+ const onKick = () =>
1609
+ steps.filter((s) => kick.has(s)).length / Math.max(1, steps.length);
1610
+ const off = steps.filter(
1611
+ (s) => !kick.has(s) && s % plan.stepsPerBar !== 0,
1612
+ );
1613
+ while (onKick() < lock && off.length > 0) {
1614
+ const drop = off.pop()!;
1615
+ steps = steps.filter((s) => s !== drop);
1616
+ }
1617
+ }
1618
+ }
1619
+ const bassIndex = (semis: number) => {
1620
+ let index = indexNear(plan, semis);
1621
+ while (toneAt(plan, index).semis < low) index += plan.scale.tones.length;
1622
+ while (toneAt(plan, index).semis > high) index -= plan.scale.tones.length;
1623
+ return index;
1624
+ };
1625
+ const rootPitch = (chord: PlannedChord | undefined): Pitch => {
1626
+ if (!chord) return toneAt(plan, bassIndex(low + 7));
1627
+ if (chord.degrees) {
1628
+ const index = bassIndex(low + 7);
1629
+ const size = plan.scale.tones.length;
1630
+ return toneAt(plan, index - mod(index, size) + chord.rootPc);
1631
+ }
1632
+ let key = low + mod(chord.rootPc - low, 12);
1633
+ if (key > high) key -= 12;
1634
+ return { key, cents: 0, semis: key };
1635
+ };
1636
+ const fold = (pitch: Pitch): Pitch => {
1637
+ if (pitch.semis > high + 0.01)
1638
+ return {
1639
+ key: pitch.key - plan.scale.period,
1640
+ cents: pitch.cents,
1641
+ semis: pitch.semis - plan.scale.periodSemis,
1642
+ };
1643
+ if (pitch.semis < low - 0.01)
1644
+ return {
1645
+ key: pitch.key + plan.scale.period,
1646
+ cents: pitch.cents,
1647
+ semis: pitch.semis + plan.scale.periodSemis,
1648
+ };
1649
+ return pitch;
1650
+ };
1651
+ const fifthOf = (root: Pitch, chord: PlannedChord | undefined): Pitch => {
1652
+ if (chord && !chord.degrees) {
1653
+ const fifth = chord.pcs.find((pc) =>
1654
+ [6, 7, 8].includes(mod(pc - chord.rootPc, 12)),
1655
+ );
1656
+ const k =
1657
+ root.key + (fifth === undefined ? 7 : mod(fifth - chord.rootPc, 12));
1658
+ return fold({ key: k, cents: 0, semis: k });
1659
+ }
1660
+ return fold(
1661
+ toneAt(plan, indexNear(plan, root.semis) + nearestDegree(plan.scale, 7)),
1662
+ );
1663
+ };
1664
+ // Ostinato: one seeded bar of scale offsets, repeated.
1665
+ const ostinato = Array.from({ length: plan.stepsPerBar }, () =>
1666
+ pickWeighted(
1667
+ [
1668
+ [0, 4],
1669
+ [2, 1],
1670
+ [4, 2],
1671
+ [-1, 1],
1672
+ [7, 1],
1673
+ ],
1674
+ random,
1675
+ ),
1676
+ );
1677
+ const out: Draft[] = [];
1678
+ const timing = random;
1679
+ steps.forEach((step, i) => {
1680
+ const bar = step / plan.stepsPerBar;
1681
+ const section = sectionAt(plan, Math.floor(bar));
1682
+ if (!section.roles.includes("bass")) return;
1683
+ const chord = chordAt(plan.chords, bar);
1684
+ const root = rootPitch(chord);
1685
+ let pitch: Pitch = root;
1686
+ const beat = Math.floor((step % plan.stepsPerBar) / beatSteps);
1687
+ switch (behaviour) {
1688
+ case "root-fifth":
1689
+ pitch = beat % 2 === 0 ? root : fifthOf(root, chord);
1690
+ break;
1691
+ case "octave":
1692
+ pitch =
1693
+ i % 2 === 0
1694
+ ? root
1695
+ : fold({
1696
+ key: root.key + plan.scale.period,
1697
+ cents: root.cents,
1698
+ semis: root.semis + plan.scale.periodSemis,
1699
+ });
1700
+ break;
1701
+ case "pedal":
1702
+ pitch = toneAt(
1703
+ plan,
1704
+ bassIndex(low + 7) - mod(bassIndex(low + 7), plan.scale.tones.length),
1705
+ );
1706
+ break;
1707
+ case "arpeggio": {
1708
+ const tones =
1709
+ chord && !chord.degrees
1710
+ ? chord.pcs.map((pc) =>
1711
+ fold({
1712
+ key: root.key + mod(pc - chord.rootPc, 12),
1713
+ cents: 0,
1714
+ semis: root.semis + mod(pc - chord.rootPc, 12),
1715
+ }),
1716
+ )
1717
+ : chordIndices(plan, chord ?? plan.chords[0]!).map((d) =>
1718
+ fold(
1719
+ toneAt(
1720
+ plan,
1721
+ indexNear(plan, root.semis) -
1722
+ mod(
1723
+ indexNear(plan, root.semis),
1724
+ plan.scale.tones.length,
1725
+ ) +
1726
+ d,
1727
+ ),
1728
+ ),
1729
+ );
1730
+ pitch = tones.length ? tones[i % tones.length]! : root;
1731
+ break;
1732
+ }
1733
+ case "ostinato": {
1734
+ const offset = ostinato[step % plan.stepsPerBar]!;
1735
+ pitch = fold(toneAt(plan, indexNear(plan, root.semis) + offset));
1736
+ break;
1737
+ }
1738
+ case "walking": {
1739
+ const nextChord = chordAt(
1740
+ plan.chords,
1741
+ (step + beatSteps) / plan.stepsPerBar,
1742
+ );
1743
+ const nextIsNew =
1744
+ nextChord !== chord || (step + beatSteps) % plan.stepsPerBar === 0;
1745
+ const walk = style.bass.walk ?? {
1746
+ chordToneOnOne: 0.9,
1747
+ chromaticApproach: 0.3,
1748
+ };
1749
+ if (step % plan.stepsPerBar === 0) {
1750
+ pitch = random() < walk.chordToneOnOne ? root : fifthOf(root, chord);
1751
+ } else if (
1752
+ nextIsNew &&
1753
+ random() < walk.chromaticApproach &&
1754
+ !nextChord?.degrees &&
1755
+ plan.scale.period === 12
1756
+ ) {
1757
+ const target = rootPitch(nextChord);
1758
+ const k = target.key + (random() < 0.5 ? -1 : 1);
1759
+ pitch = fold({ key: k, cents: 0, semis: k });
1760
+ } else {
1761
+ const prev = out[out.length - 1];
1762
+ const from = prev
1763
+ ? indexNear(plan, prev.key + prev.cents / 100)
1764
+ : indexNear(plan, root.semis);
1765
+ const target = indexNear(plan, rootPitch(nextChord).semis);
1766
+ const dir =
1767
+ target > from ? 1 : target < from ? -1 : random() < 0.5 ? 1 : -1;
1768
+ pitch = fold(toneAt(plan, from + dir));
1769
+ }
1770
+ break;
1771
+ }
1772
+ default:
1773
+ pitch = root;
1774
+ }
1775
+ const next = steps[i + 1] ?? Math.min(totalSteps, step + plan.stepsPerBar);
1776
+ out.push({
1777
+ trackId: "bass",
1778
+ role: "bass",
1779
+ tick: timeOf("bass", step, timing),
1780
+ duration: Math.max(30, (next - step) * plan.stepTicks * 0.9),
1781
+ key: pitch.key,
1782
+ cents: pitch.cents,
1783
+ velocity: clamp(velocityAt(step, section.energy, "bass"), 0.05, 1),
1784
+ });
1785
+ });
1786
+ return out;
1787
+ }
1788
+
1789
+ const CONTOUR_TARGETS: Readonly<Record<string, (x: number) => number>> =
1790
+ Object.freeze({
1791
+ arch: (x: number) => Math.sin(Math.PI * x),
1792
+ ascending: (x: number) => x,
1793
+ descending: (x: number) => 1 - x,
1794
+ wave: (x: number) => 0.5 + 0.5 * Math.sin(2 * Math.PI * x),
1795
+ flat: () => 0.5,
1796
+ terraced: (x: number) => Math.floor(x * 3) / 2,
1797
+ });
1798
+
1799
+ function playMelody(
1800
+ plan: Plan,
1801
+ role: "lead" | "counter",
1802
+ random: Random,
1803
+ ): { drafts: Draft[]; phrases: { startBar: number; bars: number }[] } {
1804
+ const style = plan.style;
1805
+ const melody = style.melody;
1806
+ const out: Draft[] = [];
1807
+ const phrases: { startBar: number; bars: number }[] = [];
1808
+ const shift = role === "counter" ? -12 : 0;
1809
+ const low = Math.round(melody.range[0]) + shift;
1810
+ const high = Math.round(melody.range[1]) + shift;
1811
+ const size = plan.scale.tones.length;
1812
+ const raga = style.pitch.raga;
1813
+ const maqam = style.pitch.maqam;
1814
+ const beatSteps = Math.max(
1815
+ 1,
1816
+ Math.round(plan.stepsPerBar / plan.beatsPerBar),
1817
+ );
1818
+ const inRange = (index: number) => {
1819
+ const s = toneAt(plan, index).semis;
1820
+ return s >= low - 1e-9 && s <= high + 1e-9;
1821
+ };
1822
+ const allowed = (index: number, direction: number) => {
1823
+ if (!raga) return true;
1824
+ const semis = Math.round(plan.scale.tones[mod(index, size)]!.semis);
1825
+ const set = direction >= 0 ? raga.aroha : raga.avaroha;
1826
+ return set.some((d) => mod(d, 12) === mod(semis, 12));
1827
+ };
1828
+ let previousRhythm: number[] | undefined;
1829
+ let bar = 0;
1830
+ let sayrAt = 0;
1831
+ let current = indexNear(plan, clamp(plan.rootKey + 7 + shift, low, high));
1832
+ while (bar < plan.bars) {
1833
+ const length = Math.min(
1834
+ plan.bars - bar,
1835
+ Math.max(1, Math.round(pickWeighted(melody.phraseBars, random))),
1836
+ );
1837
+ phrases.push({ startBar: bar, bars: length });
1838
+ const contour =
1839
+ CONTOUR_TARGETS[pickWeighted(melody.contour, random)] ??
1840
+ CONTOUR_TARGETS.arch!;
1841
+ const ambitus = clamp(between(melody.ambitus, random), 2, high - low);
1842
+ // Phrase register: a maqam's sayr walks the phrase centres up.
1843
+ let centre = (low + high) / 2;
1844
+ if (maqam?.sayr.length) {
1845
+ const degree = maqam.sayr[sayrAt % maqam.sayr.length]!;
1846
+ sayrAt += 1;
1847
+ centre = clamp(
1848
+ toneAt(plan, indexNear(plan, plan.rootKey + shift) + degree).semis,
1849
+ low + ambitus / 2,
1850
+ high - ambitus / 2,
1851
+ );
1852
+ }
1853
+ const floor = clamp(centre - ambitus / 2, low, high);
1854
+ const ceil = clamp(centre + ambitus / 2, low, high);
1855
+ // Rhythm: onsets per beat from the density range, or the last phrase's.
1856
+ let rhythm: number[];
1857
+ const totalSteps = length * plan.stepsPerBar;
1858
+ if (
1859
+ previousRhythm &&
1860
+ previousRhythm.length &&
1861
+ random() < melody.repetition &&
1862
+ previousRhythm[previousRhythm.length - 1]! < totalSteps
1863
+ )
1864
+ rhythm = previousRhythm;
1865
+ else {
1866
+ rhythm = [];
1867
+ for (let beat = 0; beat * beatSteps < totalSteps; beat += 1) {
1868
+ const count = clamp(
1869
+ Math.round(between(melody.density, random)),
1870
+ 0,
1871
+ beatSteps,
1872
+ );
1873
+ const options = [...Array(beatSteps).keys()];
1874
+ const picks: number[] = [];
1875
+ for (let c = 0; c < count; c += 1) {
1876
+ const at =
1877
+ c === 0 && random() < 0.7
1878
+ ? 0
1879
+ : Math.floor(random() * options.length);
1880
+ picks.push(options.splice(Math.min(at, options.length - 1), 1)[0]!);
1881
+ }
1882
+ for (const p of picks.sort((a, b) => a - b)) {
1883
+ const step = beat * beatSteps + p;
1884
+ if (step < totalSteps) rhythm.push(step);
1885
+ }
1886
+ }
1887
+ if (rhythm.length === 0) rhythm.push(0);
1888
+ }
1889
+ previousRhythm = rhythm;
1890
+ const finalDegree = mod(
1891
+ Math.round(pickWeighted(melody.finals, random)),
1892
+ size,
1893
+ );
1894
+ rhythm.forEach((local, n) => {
1895
+ const step = bar * plan.stepsPerBar + local;
1896
+ const x = rhythm.length > 1 ? n / (rhythm.length - 1) : 0;
1897
+ const target = floor + (ceil - floor) * clamp(contour(x), 0, 1);
1898
+ const last = n === rhythm.length - 1;
1899
+ let next: number;
1900
+ if (last) {
1901
+ // Phrase final: the card's final degree nearest the line.
1902
+ const near = indexNear(plan, toneAt(plan, current).semis);
1903
+ next = near - mod(near, size) + finalDegree;
1904
+ if (toneAt(plan, next).semis - toneAt(plan, current).semis > 6)
1905
+ next -= size;
1906
+ if (toneAt(plan, current).semis - toneAt(plan, next).semis > 6)
1907
+ next += size;
1908
+ while (!inRange(next) && toneAt(plan, next).semis < low) next += size;
1909
+ while (!inRange(next) && toneAt(plan, next).semis > high) next -= size;
1910
+ } else if (n === 0 && raga?.pakad?.length && random() < 0.35) {
1911
+ next = indexNear(plan, plan.rootKey + shift + raga.pakad[0]![0]!);
1912
+ } else {
1913
+ // Weighted by interval size, pulled toward the contour target.
1914
+ const from = toneAt(plan, current).semis;
1915
+ let best = current;
1916
+ let bestScore = -Infinity;
1917
+ const candidates: [number, number][] = [];
1918
+ for (let d = -size; d <= size; d += 1) {
1919
+ const index = current + d;
1920
+ if (!inRange(index)) continue;
1921
+ const semis = toneAt(plan, index).semis;
1922
+ const interval = Math.round(semis - from);
1923
+ if (Math.abs(interval) > 12) continue;
1924
+ if (!allowed(index, semis - from)) continue;
1925
+ let weight = melody.intervals[interval + 12] ?? 0;
1926
+ const pull = Math.abs(semis - target);
1927
+ weight *= Math.exp(-pull / 4);
1928
+ if (
1929
+ raga &&
1930
+ mod(Math.round(plan.scale.tones[mod(index, size)]!.semis), 12) ===
1931
+ mod(raga.vadi, 12)
1932
+ )
1933
+ weight *= 1.6;
1934
+ candidates.push([index, weight]);
1935
+ }
1936
+ if (candidates.length) best = pickWeighted(candidates, random);
1937
+ else {
1938
+ for (let d = -size; d <= size; d += 1)
1939
+ if (inRange(current + d) && -Math.abs(d) > bestScore) {
1940
+ bestScore = -Math.abs(d);
1941
+ best = current + d;
1942
+ }
1943
+ }
1944
+ next = best;
1945
+ // Strong beats lean on chord tones under functional harmony.
1946
+ const strong = local % beatSteps === 0;
1947
+ if (
1948
+ strong &&
1949
+ plan.harmonyModel === "functional" &&
1950
+ random() < melody.chordToneRate
1951
+ ) {
1952
+ const chord = chordAt(plan.chords, step / plan.stepsPerBar);
1953
+ if (chord) {
1954
+ const tones = chordIndices(plan, chord);
1955
+ let snap = next;
1956
+ let distance = Infinity;
1957
+ for (let d = -3; d <= 3; d += 1) {
1958
+ const index = next + d;
1959
+ if (
1960
+ tones.includes(mod(index, size)) &&
1961
+ inRange(index) &&
1962
+ Math.abs(d) < distance
1963
+ ) {
1964
+ distance = Math.abs(d);
1965
+ snap = index;
1966
+ }
1967
+ }
1968
+ next = snap;
1969
+ }
1970
+ }
1971
+ }
1972
+ if (!inRange(next)) next = current;
1973
+ // No leap past an octave inside a phrase (finals, snaps and pakad
1974
+ // entries fold back toward the line).
1975
+ if (n > 0) {
1976
+ const from = toneAt(plan, current).semis;
1977
+ while (toneAt(plan, next).semis - from > 12 && inRange(next - size))
1978
+ next -= size;
1979
+ while (from - toneAt(plan, next).semis > 12 && inRange(next + size))
1980
+ next += size;
1981
+ if (Math.abs(toneAt(plan, next).semis - from) > 12) next = current;
1982
+ }
1983
+ current = next;
1984
+ const pitch = toneAt(plan, current);
1985
+ const end = rhythm[n + 1] ?? totalSteps;
1986
+ const section = sectionAt(plan, Math.floor(step / plan.stepsPerBar));
1987
+ if (!section.roles.includes(role)) return;
1988
+ const [lo, hi] = style.expression.dynamics;
1989
+ out.push({
1990
+ trackId: role,
1991
+ role,
1992
+ tick: swungStepTick(plan, step),
1993
+ duration: Math.max(
1994
+ 30,
1995
+ Math.min((end - local) * plan.stepTicks, plan.barTicks) * 0.9,
1996
+ ),
1997
+ key: pitch.key,
1998
+ cents: pitch.cents,
1999
+ velocity: clamp(
2000
+ (lo + (hi - lo) * section.energy) *
2001
+ (local % beatSteps === 0 ? 1 : 0.85) *
2002
+ (role === "counter" ? 0.8 : 1),
2003
+ 0.05,
2004
+ 1,
2005
+ ),
2006
+ });
2007
+ });
2008
+ bar += length;
2009
+ }
2010
+ return { drafts: out, phrases };
2011
+ }
2012
+
2013
+ // ---------------------------------------------------------------------------
2014
+ // Assembly
2015
+
2016
+ /**
2017
+ * The song master for a style: its loudness target over a true-peak
2018
+ * limiter (the target's ceiling and limiter preset), so a generated song
2019
+ * never clips however many roles stack up.
2020
+ */
2021
+ export function styleMaster(style: ResolvedStyle): SongMaster {
2022
+ const name = style.mix.loudness ?? "streaming";
2023
+ if (!isLoudnessTargetName(name))
2024
+ throw new StyleError(`${style.id}: unknown loudness target "${name}"`);
2025
+ const target = LOUDNESS_TARGETS[name] as {
2026
+ lufs: number;
2027
+ ceiling: number;
2028
+ limiter?: string;
2029
+ };
2030
+ const preset = MASTER_PRESETS.limiter[target.limiter ?? "transparent"] ?? {};
2031
+ return Object.freeze({
2032
+ limiter: Object.freeze({ ...preset, ceiling: target.ceiling }),
2033
+ target: target.lufs,
2034
+ });
2035
+ }
2036
+
2037
+ const dbToVolume = (db: number) => clamp(0.8 * 10 ** (db / 20), 0, 1);
2038
+
2039
+ function assemble(plan: Plan, drafts: Draft[]): GeneratedStyle {
2040
+ const style = plan.style;
2041
+ const loopTicks = plan.bars * plan.barTicks;
2042
+ // Notes start inside the song and are integral ticks.
2043
+ const cleaned = drafts
2044
+ .map((draft) => ({
2045
+ ...draft,
2046
+ tick: clamp(Math.round(draft.tick), 0, loopTicks - 1),
2047
+ duration: Math.max(1, Math.round(draft.duration)),
2048
+ }))
2049
+ .filter(
2050
+ (draft) =>
2051
+ draft.key >= 0 && draft.key <= 127 && Number.isFinite(draft.velocity),
2052
+ );
2053
+ cleaned.sort(
2054
+ (a, b) =>
2055
+ a.tick - b.tick || a.trackId.localeCompare(b.trackId) || a.key - b.key,
2056
+ );
2057
+ // Over the note cap: thin the quietest percussion first.
2058
+ if (cleaned.length > SCORE_LIMITS.maxNotes) {
2059
+ const order = cleaned
2060
+ .map((draft, i) => ({
2061
+ i,
2062
+ weight:
2063
+ (KIT_ROLES as readonly string[]).includes(draft.role) ||
2064
+ (PERC_ROLES as readonly string[]).includes(draft.role)
2065
+ ? draft.velocity
2066
+ : 2 + draft.velocity,
2067
+ }))
2068
+ .sort((a, b) => a.weight - b.weight || a.i - b.i);
2069
+ const drop = new Set(
2070
+ order
2071
+ .slice(0, cleaned.length - SCORE_LIMITS.maxNotes)
2072
+ .map((entry) => entry.i),
2073
+ );
2074
+ for (let i = cleaned.length - 1; i >= 0; i -= 1)
2075
+ if (drop.has(i)) cleaned.splice(i, 1);
2076
+ }
2077
+ const noteRoles = new Map<string, RoleName>();
2078
+ const counters = new Map<string, number>();
2079
+ const notes: NoteInput[] = cleaned.map((draft) => {
2080
+ const n = (counters.get(draft.trackId) ?? 0) + 1;
2081
+ counters.set(draft.trackId, n);
2082
+ const id = `${draft.trackId}-${n}`;
2083
+ noteRoles.set(id, draft.role);
2084
+ return {
2085
+ id,
2086
+ trackId: draft.trackId,
2087
+ startTick: draft.tick,
2088
+ durationTicks: Math.min(draft.duration, loopTicks - draft.tick),
2089
+ pitch: draft.key,
2090
+ velocity: Math.round(draft.velocity * 1000) / 1000,
2091
+ ...(draft.cents ? { cents: draft.cents } : {}),
2092
+ ...(draft.articulation
2093
+ ? { articulation: draft.articulation as NoteInput["articulation"] }
2094
+ : {}),
2095
+ };
2096
+ });
2097
+ const space = clamp(style.mix.space, 0, 1);
2098
+ const tracks: TrackInput[] = plan.tracks.map((track) => {
2099
+ const fields = instrumentFields(track.voice);
2100
+ const fx = roleFx(style, track.roles);
2101
+ const level = Math.max(
2102
+ ...track.roles.map((role) => style.mix.levels?.[role] ?? -6),
2103
+ );
2104
+ const pan = clamp(style.mix.pan?.[track.roles[0]!] ?? 0, -1, 1);
2105
+ const pitched = (PITCHED_ROLES as readonly string[]).includes(
2106
+ track.roles[0]!,
2107
+ );
2108
+ return {
2109
+ ...fields,
2110
+ id: track.trackId,
2111
+ name: track.trackId,
2112
+ volume: Math.round(dbToVolume(level) * 1000) / 1000,
2113
+ pan,
2114
+ ...(fx ? { fx: { ...(fields.fx ?? {}), ...fx } } : {}),
2115
+ ...(!fields.reverb && pitched && space > 0.02
2116
+ ? {
2117
+ reverb: {
2118
+ mix: Math.round(space * 0.45 * 1000) / 1000,
2119
+ size: Math.round((0.3 + space * 0.6) * 1000) / 1000,
2120
+ },
2121
+ }
2122
+ : {}),
2123
+ } as TrackInput;
2124
+ });
2125
+ const sections: Section[] = plan.sections.map((section) => {
2126
+ const mute = plan.tracks
2127
+ .filter(
2128
+ (track) => !track.roles.some((role) => section.roles.includes(role)),
2129
+ )
2130
+ .map((track) => track.trackId);
2131
+ return {
2132
+ name: section.name,
2133
+ startBar: section.startBar,
2134
+ bars: section.bars,
2135
+ ...(mute.length ? { mute } : {}),
2136
+ };
2137
+ });
2138
+ const time =
2139
+ plan.beatUnit === 4
2140
+ ? undefined
2141
+ : {
2142
+ meter: [
2143
+ { bar: 0, beatsPerBar: plan.beatsPerBar, beatUnit: plan.beatUnit },
2144
+ ],
2145
+ };
2146
+ const master = styleMaster(style);
2147
+ const blend = (
2148
+ style as ResolvedStyle & {
2149
+ blend?: { a: string; b: string; weight: number };
2150
+ }
2151
+ ).blend;
2152
+ const provenance: StyleProvenance = {
2153
+ id: blend ? blend.a : style.id,
2154
+ seed: plan.seed,
2155
+ bars: plan.bars,
2156
+ ...(blend ? { blend: { id: blend.b, weight: blend.weight } } : {}),
2157
+ };
2158
+ const data: TrackScoreData = {
2159
+ master,
2160
+ tempoBpm: plan.bpm,
2161
+ beatsPerBar: plan.beatsPerBar,
2162
+ bars: plan.bars,
2163
+ key: plan.keyText,
2164
+ ...(time ? { time } : {}),
2165
+ ...(plan.tuning ? { tuning: plan.tuning } : {}),
2166
+ tracks,
2167
+ notes,
2168
+ style: provenance,
2169
+ ...(sections.length > 1 ? { sections } : {}),
2170
+ };
2171
+ const operations: ScoreOperation[] = [
2172
+ { type: "setTempo", tempoBpm: plan.bpm },
2173
+ { type: "setBars", bars: plan.bars },
2174
+ ...(time
2175
+ ? [{ type: "setTime", time } as ScoreOperation]
2176
+ : [
2177
+ { type: "setMeter", beatsPerBar: plan.beatsPerBar } as ScoreOperation,
2178
+ ]),
2179
+ { type: "setKey", key: plan.keyText },
2180
+ { type: "setTuning", tuning: plan.tuning ?? null },
2181
+ { type: "setMaster", master },
2182
+ { type: "setStyle", style: provenance },
2183
+ ...tracks.map((track) => ({ type: "addTrack", track }) as ScoreOperation),
2184
+ ...notes.map((note) => ({ type: "addNote", note }) as ScoreOperation),
2185
+ ...(sections.length > 1
2186
+ ? [{ type: "setSections", sections, form: [] } as ScoreOperation]
2187
+ : []),
2188
+ ];
2189
+ const full: StylePlan = { ...plan, noteRoles };
2190
+ return { plan: full, data, operations, provenance };
2191
+ }
2192
+
2193
+ /** The generated song as a score. */
2194
+ export function styleScore(generated: GeneratedStyle): TrackScore {
2195
+ return createScore(generated.data);
2196
+ }
2197
+
2198
+ /** Every role name, for surfaces that list them. */
2199
+ export const STYLE_ROLE_NAMES: readonly RoleName[] = ROLE_NAMES;