opennote-cli 1.3.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 (50) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +272 -0
  3. package/package.json +30 -0
  4. package/src/arrangement.js +282 -0
  5. package/src/arrangement.ts +317 -0
  6. package/src/assets/cover.png +0 -0
  7. package/src/cli.js +1398 -0
  8. package/src/cli.ts +1709 -0
  9. package/src/exportAudio.js +459 -0
  10. package/src/exportAudio.ts +499 -0
  11. package/src/exportMidi.js +85 -0
  12. package/src/exportMidi.ts +103 -0
  13. package/src/ffmpeg-static.d.ts +5 -0
  14. package/src/fx.js +28 -0
  15. package/src/fx.ts +49 -0
  16. package/src/generator.js +15 -0
  17. package/src/generator.ts +29 -0
  18. package/src/index.js +511 -0
  19. package/src/index.ts +642 -0
  20. package/src/instrument.js +35 -0
  21. package/src/instrument.ts +51 -0
  22. package/src/midi.js +167 -0
  23. package/src/midi.ts +218 -0
  24. package/src/openExport.js +22 -0
  25. package/src/openExport.ts +24 -0
  26. package/src/prompt.js +22 -0
  27. package/src/prompt.ts +25 -0
  28. package/src/providers/auth.js +23 -0
  29. package/src/providers/auth.ts +30 -0
  30. package/src/providers/claudeProvider.js +46 -0
  31. package/src/providers/claudeProvider.ts +50 -0
  32. package/src/providers/factory.js +39 -0
  33. package/src/providers/factory.ts +43 -0
  34. package/src/providers/geminiProvider.js +55 -0
  35. package/src/providers/geminiProvider.ts +71 -0
  36. package/src/providers/grokProvider.js +57 -0
  37. package/src/providers/grokProvider.ts +69 -0
  38. package/src/providers/groqProvider.js +57 -0
  39. package/src/providers/groqProvider.ts +69 -0
  40. package/src/providers/mockProvider.js +13 -0
  41. package/src/providers/mockProvider.ts +15 -0
  42. package/src/providers/openaiProvider.js +45 -0
  43. package/src/providers/openaiProvider.ts +49 -0
  44. package/src/providers/retry.js +46 -0
  45. package/src/providers/retry.ts +54 -0
  46. package/src/types.js +1 -0
  47. package/src/types.ts +17 -0
  48. package/src/validate.js +10 -0
  49. package/src/validate.ts +13 -0
  50. package/tsconfig.json +13 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenNote contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,272 @@
1
+ # OpenNote
2
+
3
+ One note, unlimited vibes.
4
+
5
+ ## Setup
6
+ ```bash
7
+ npm install
8
+ ```
9
+
10
+ ## Run
11
+ ```bash
12
+ npm run note
13
+ ```
14
+
15
+ ## Quick templates
16
+ Copy/paste one:
17
+
18
+ 1. Dark grime lead
19
+ ```bash
20
+ npm run note -- --no-interactive --provider=mock --instrument=lead --fx=grime --decay=long --theme="dark industrial pulse" --length=16 --bpm=130 --seed-source=manual --seed=60 --export-midi=./exports/template-01-dark-grime.mid --export-audio=mp4 --open-after-export=finder
21
+ ```
22
+
23
+ 2. Techno bass drive
24
+ ```bash
25
+ npm run note -- --no-interactive --provider=mock --instrument=bass --fx=punch --decay=tight --theme="minimal techno groove" --length=16 --bpm=132 --seed-source=manual --seed=48 --export-midi=./exports/template-02-techno-bass.mid --export-audio=mp4 --open-after-export=finder
26
+ ```
27
+
28
+ 3. Lush ambient pad
29
+ ```bash
30
+ npm run note -- --no-interactive --provider=mock --instrument=pad --fx=lush --decay=long --theme="ambient cinematic" --length=24 --bpm=96 --seed-source=manual --seed=52 --export-midi=./exports/template-03-lush-ambient.mid --export-audio=mp4 --open-after-export=finder
31
+ ```
32
+
33
+ 4. Retro synthwave keys
34
+ ```bash
35
+ npm run note -- --no-interactive --provider=mock --instrument=keys --fx=dark --decay=balanced --theme="synthwave retro neon" --length=16 --bpm=118 --seed-source=manual --seed=64 --export-midi=./exports/template-04-synthwave-keys.mid --export-audio=mp4 --open-after-export=finder
36
+ ```
37
+
38
+ 5. Trap lead sparse
39
+ ```bash
40
+ npm run note -- --no-interactive --provider=mock --instrument=lead --fx=punch --decay=tight --theme="trap melodic lead" --length=12 --bpm=145 --seed-source=manual --seed=57 --export-midi=./exports/template-05-trap-lead.mid --export-audio=mp4 --open-after-export=finder
41
+ ```
42
+
43
+ 6. House keys groove
44
+ ```bash
45
+ npm run note -- --no-interactive --provider=mock --instrument=keys --fx=clean --decay=balanced --theme="melodic house uplifting" --length=16 --bpm=124 --seed-source=manual --seed=62 --export-midi=./exports/template-06-house-keys.mid --export-audio=mp4 --open-after-export=finder
46
+ ```
47
+
48
+ 7. Neo-soul keys smooth
49
+ ```bash
50
+ npm run note -- --no-interactive --provider=mock --instrument=keys --fx=clean --decay=long --theme="jazz neo-soul phrasing" --length=16 --bpm=92 --seed-source=manual --seed=65 --export-midi=./exports/template-07-neo-soul.mid --export-audio=mp4 --open-after-export=finder
51
+ ```
52
+
53
+ 8. Industrial drums
54
+ ```bash
55
+ npm run note -- --no-interactive --provider=mock --instrument=drums --fx=grime --decay=tight --theme="dark industrial pulse" --length=16 --bpm=136 --seed-source=manual --seed=60 --export-midi=./exports/template-08-industrial-drums.mid --export-audio=mp4 --open-after-export=finder
56
+ ```
57
+
58
+ 9. Lo-fi chill line
59
+ ```bash
60
+ npm run note -- --no-interactive --provider=mock --instrument=keys --fx=dark --decay=balanced --theme="lofi chillhop" --length=16 --bpm=88 --seed-source=manual --seed=58 --export-midi=./exports/template-09-lofi.mid --export-audio=mp4 --open-after-export=finder
61
+ ```
62
+
63
+ 10. Export-ready MP4 run
64
+ ```bash
65
+ npm run note -- --no-interactive --provider=mock --instrument=lead --fx=grime --decay=long --theme="industrial" --length=16 --bpm=130 --seed-source=manual --seed=60 --export-midi=./exports/take-01.mid --export-audio=mp4 --open-after-export=finder
66
+ ```
67
+
68
+ 11. AI backing (techno drums + bass)
69
+ ```bash
70
+ npm run note -- --no-interactive --provider=mock --mode=backing --instrument=lead --fx=punch --decay=balanced --theme="minimal techno groove" --length=16 --bpm=132 --seed-source=manual --seed=60 --transpose=0 --pitch-range=mid --snap-scale=true --mod-rate=med --mod-depth=25 --mod-target=velocity --growth=build --duration-stretch=1.5 --timing-feel=offbeat --timing-amount=35 --backing-drums=true --backing-bass=true --backing-clap=true --backing-open-hat=true --backing-perc=true --metronome=count-in --swing=18 --gate=balanced --mutate=12 --deviate=10 --export-midi=./exports/template-11-ai-backing.mid --export-audio=mp4 --open-after-export=finder
71
+ ```
72
+
73
+ 12. Ambient growth (long phrases + human timing)
74
+ ```bash
75
+ npm run note -- --no-interactive --provider=mock --mode=single --instrument=pad --fx=lush --decay=long --theme="ambient cinematic" --length=24 --bpm=94 --seed-source=manual --seed=52 --growth=build --duration-stretch=2 --timing-feel=human --timing-amount=22 --export-midi=./exports/template-12-ambient-growth.mid --export-audio=mp4 --open-after-export=finder
76
+ ```
77
+
78
+ 13. Trap pocket (offbeat hats + clap)
79
+ ```bash
80
+ npm run note -- --no-interactive --provider=mock --mode=backing --instrument=lead --fx=grime --decay=tight --theme="trap melodic lead" --length=16 --bpm=145 --seed-source=manual --seed=57 --growth=build --duration-stretch=1 --timing-feel=offbeat --timing-amount=45 --backing-drums=true --backing-bass=true --backing-clap=true --backing-open-hat=true --backing-perc=false --metronome=count-in --swing=10 --gate=tight --mutate=14 --deviate=12 --export-midi=./exports/template-13-trap-pocket.mid --export-audio=mp4 --open-after-export=finder
81
+ ```
82
+
83
+ 14. Loose industrial groove (perc heavy)
84
+ ```bash
85
+ npm run note -- --no-interactive --provider=mock --mode=backing --instrument=keys --fx=dark --decay=balanced --theme="dark industrial pulse" --length=16 --bpm=132 --seed-source=manual --seed=60 --growth=build --duration-stretch=1.25 --timing-feel=loose --timing-amount=38 --backing-drums=true --backing-bass=false --backing-clap=false --backing-open-hat=true --backing-perc=true --metronome=count-in --swing=14 --gate=balanced --mutate=16 --deviate=15 --export-midi=./exports/template-14-industrial-loose.mid --export-audio=mp4 --open-after-export=finder
86
+ ```
87
+
88
+ ## Interactive flow
89
+ The CLI uses arrow keys + Enter and runs this setup:
90
+
91
+ 1. Provider
92
+ - Demo mode (`mock`) or live provider (`openai`, `gemini`, `claude`, `groq`, `grok`)
93
+ - If provider key is missing, CLI prompts for it
94
+
95
+ 2. Setup path
96
+ - `basic`: quick run with smart defaults (`single`, `lead`, `clean`, `balanced`, `mp4 + Finder`) plus style/structure/seed
97
+ - `surprise me`: instantly auto-picks full config and goes straight to generation
98
+ - `advanced`: full controls for pitch, modulation, and groove
99
+
100
+ Advanced-only steps:
101
+
102
+ 3. Mode
103
+ - `single` (default): melody-only flow
104
+ - `backing`: reveals drums/bass/metronome and groove controls
105
+
106
+ 4. Instrument
107
+ - `lead`, `bass`, `pad`, `keys`, `drums`
108
+
109
+ 5. FX
110
+ - Preset: `clean`, `dark`, `grime`, `lush`, `punch`
111
+ - Decay: `tight`, `balanced`, `long`
112
+
113
+ 6. Pitch
114
+ - `transpose` via arrow flow: direction (`Down/Neutral/Up`) + amount (`1..12`)
115
+ - `range` (`low|mid|high`)
116
+ - `snap to scale` (`on|off`)
117
+
118
+ 7. Modulate
119
+ - `rate` (`off|slow|med|fast`)
120
+ - `depth` (`0..100`)
121
+ - `target` (`velocity|duration|pitch`)
122
+
123
+ 8. Movement
124
+ - `growth` (`flat|build`) for song energy over time
125
+ - `duration stretch` (`1.0x..3.0x`) for longer phrase feel
126
+ - `timing feel` (`tight|human|offbeat|loose`)
127
+ - `timing amount` (`0..100`)
128
+
129
+ 9. Backing controls (only when mode is `backing`)
130
+ - Drums on/off
131
+ - Bass on/off
132
+ - Clap on/off
133
+ - Open hat on/off
134
+ - Perc on/off
135
+ - Metronome (`off|count-in|always`)
136
+ - Swing (`0..100`)
137
+ - Gate (`tight|balanced|long`)
138
+ - Mutate (`0..100`)
139
+ - Deviate (`0..100`)
140
+
141
+ All paths include:
142
+
143
+ 10. Style
144
+ - Preset music categories or custom theme
145
+
146
+ 11. Structure
147
+ - Length (notes)
148
+ - BPM
149
+
150
+ 12. Input
151
+ - `basic` and `surprise me`: manual seed
152
+ - `advanced`: `keyboard` or `manual` mode
153
+
154
+ 13. Export open action
155
+ - `none`
156
+ - `finder`
157
+ - `garageband`
158
+
159
+ 14. Export media profile
160
+ - `mp4` (default highlighted in prompt)
161
+ - `mp3`
162
+ - `none` (MIDI only)
163
+
164
+ 15. Summary + confirm (advanced only)
165
+ - `Start generation`
166
+ - `Back to setup`
167
+
168
+ After playback, action menu order is:
169
+ 1. `Export MIDI + <open action> + finish`
170
+ 2. `Export MIDI + <open action> + retry`
171
+ 3. `Retry (new take)`
172
+ 4. `Finish`
173
+
174
+ Notes:
175
+ - Export path auto-generates timestamped filename under `./exports/` unless `--export-midi=...` is provided.
176
+ - MP4 cover image is fixed to `./src/assets/cover.png` (replace that file in the repo to change cover art).
177
+ - MP3/MP4 exports also write MIDI stems by default (`-melody.mid`, `-bass.mid`, `-drums.mid` when present).
178
+
179
+ ## Cover art workflow
180
+ - Replace `./src/assets/cover.png` with your own image.
181
+ - Drag your image into Figma or Affinity, design album art, export as PNG.
182
+ - Save it back to `./src/assets/cover.png`.
183
+ - Run OpenNote and export MP4.
184
+ - Start making music.
185
+
186
+ ## Non-interactive usage
187
+ ```bash
188
+ npm run note -- --no-interactive --provider=mock --theme="ambient" --length=16 --bpm=120 --seed-source=manual --seed=60
189
+ ```
190
+
191
+ With export + Finder reveal:
192
+ ```bash
193
+ npm run note -- --no-interactive --provider=mock --theme="ambient" --length=16 --bpm=120 --seed-source=manual --seed=60 --export-midi=./exports/take-01.mid --open-after-export=finder --export-audio=mp4
194
+ ```
195
+
196
+ With AI backing + groove controls:
197
+ ```bash
198
+ npm run note -- --no-interactive --provider=mock --mode=backing --theme="trap melodic lead" --length=16 --bpm=140 --seed-source=manual --seed=57 --transpose=0 --pitch-range=mid --snap-scale=false --mod-rate=slow --mod-depth=20 --mod-target=velocity --growth=build --duration-stretch=1.25 --timing-feel=offbeat --timing-amount=35 --backing-drums=true --backing-bass=false --backing-clap=true --backing-open-hat=true --backing-perc=false --metronome=count-in --swing=12 --gate=tight --mutate=10 --deviate=8 --export-midi=./exports/take-backing.mid --open-after-export=finder --export-audio=mp4
199
+ ```
200
+
201
+ ## CLI flags
202
+ - `--provider=mock|openai|gemini|claude|groq|grok`
203
+ - `--instrument=lead|bass|pad|keys|drums`
204
+ - `--fx=clean|dark|grime|lush|punch`
205
+ - `--decay=tight|balanced|long`
206
+ - `--mode=single|backing`
207
+ - `--transpose=-12..12`
208
+ - `--pitch-range=low|mid|high`
209
+ - `--snap-scale=true|false`
210
+ - `--mod-rate=off|slow|med|fast`
211
+ - `--mod-depth=0..100`
212
+ - `--mod-target=velocity|duration|pitch`
213
+ - `--growth=flat|build`
214
+ - `--duration-stretch=1..4`
215
+ - `--timing-feel=tight|human|offbeat|loose`
216
+ - `--timing-amount=0..100`
217
+ - `--backing-drums=true|false`
218
+ - `--backing-bass=true|false`
219
+ - `--backing-clap=true|false`
220
+ - `--backing-open-hat=true|false`
221
+ - `--backing-perc=true|false`
222
+ - `--metronome=off|count-in|always`
223
+ - `--swing=0..100`
224
+ - `--gate=tight|balanced|long`
225
+ - `--mutate=0..100`
226
+ - `--deviate=0..100`
227
+ - `--theme="..."`
228
+ - `--length=<notes>`
229
+ - `--bpm=<tempo>`
230
+ - `--seed=<0-127>`
231
+ - `--seed-source=keyboard|manual`
232
+ - `--beep=true|false`
233
+ - `--export-midi=<path.mid>`
234
+ - `--open-after-export=none|finder|garageband`
235
+ - `--export-audio=none|mp3|mp4`
236
+ - `--export-stems=true|false`
237
+ - `--no-interactive`
238
+ - `--help`
239
+
240
+ ## Provider keys
241
+ - OpenAI: `OPENAI_API_KEY` (optional `OPENAI_MODEL`)
242
+ - Gemini: `GEMINI_API_KEY` (optional `GEMINI_MODEL`)
243
+ - Claude: `ANTHROPIC_API_KEY` (optional `CLAUDE_MODEL`)
244
+ - Groq: `GROQ_API_KEY` (optional `GROQ_MODEL`)
245
+ - Grok (xAI): `XAI_API_KEY` (optional `GROK_MODEL`)
246
+
247
+ ## npm publish checklist
248
+ 1. Keep `"private": true` during development to prevent accidental publish.
249
+ 2. Run pack preview:
250
+ ```bash
251
+ npm run pack:check
252
+ ```
253
+ 3. When ready to publish, set `"private": false` in `package.json`.
254
+ 4. Publish:
255
+ ```bash
256
+ npm publish
257
+ ```
258
+
259
+ ## Versioning
260
+ - Versioning uses SemVer: `major.minor.patch`.
261
+ - Changelog lives in `CHANGELOG.md`.
262
+ - Bump version before release:
263
+ ```bash
264
+ npm run version:patch # fixes only
265
+ npm run version:minor # new backward-compatible features
266
+ npm run version:major # breaking changes
267
+ ```
268
+
269
+ ## Export behavior
270
+ - MIDI: always supported (`.mid`)
271
+ - MP3/MP4: uses `ffmpeg-static` bundled via npm dependency
272
+ - If bundled ffmpeg is unavailable on platform, install system ffmpeg and rerun
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "opennote-cli",
3
+ "version": "1.3.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "README.md",
12
+ "LICENSE",
13
+ "tsconfig.json"
14
+ ],
15
+ "scripts": {
16
+ "note": "tsx src/index.ts",
17
+ "pack:check": "npm pack --dry-run",
18
+ "version:patch": "npm version patch",
19
+ "version:minor": "npm version minor",
20
+ "version:major": "npm version major"
21
+ },
22
+ "dependencies": {
23
+ "@inquirer/prompts": "^7.8.4",
24
+ "ffmpeg-static": "^5.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "tsx": "^4.19.3",
28
+ "typescript": "^5.7.3"
29
+ }
30
+ }
@@ -0,0 +1,282 @@
1
+ const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
2
+ function clamp(n, min, max) {
3
+ return Math.max(min, Math.min(max, n));
4
+ }
5
+ function rangeBounds(range) {
6
+ if (range === 'low')
7
+ return [28, 60];
8
+ if (range === 'high')
9
+ return [67, 108];
10
+ return [48, 84];
11
+ }
12
+ function closestScalePitch(p) {
13
+ let best = p;
14
+ let bestDist = Infinity;
15
+ for (let candidate = p - 6; candidate <= p + 6; candidate++) {
16
+ const pc = ((candidate % 12) + 12) % 12;
17
+ if (!MAJOR_SCALE.includes(pc))
18
+ continue;
19
+ const d = Math.abs(candidate - p);
20
+ if (d < bestDist) {
21
+ best = candidate;
22
+ bestDist = d;
23
+ }
24
+ }
25
+ return best;
26
+ }
27
+ function gateMultiplier(gate) {
28
+ if (gate === 'tight')
29
+ return 0.7;
30
+ if (gate === 'long')
31
+ return 1.25;
32
+ return 1;
33
+ }
34
+ function modRateCycles(rate, n) {
35
+ if (rate === 'off')
36
+ return 0;
37
+ if (rate === 'slow')
38
+ return Math.max(1, n / 16);
39
+ if (rate === 'fast')
40
+ return Math.max(2, n / 4);
41
+ return Math.max(1.5, n / 8);
42
+ }
43
+ function lfo(i, n, rate) {
44
+ const cycles = modRateCycles(rate, n);
45
+ if (cycles === 0)
46
+ return 0;
47
+ const phase = (i / Math.max(1, n - 1)) * Math.PI * 2 * cycles;
48
+ return Math.sin(phase);
49
+ }
50
+ function swingify(sequence, swing) {
51
+ if (swing <= 0)
52
+ return sequence;
53
+ const amt = clamp(swing / 100, 0, 1) * 0.22;
54
+ return sequence.map((n, i) => ({
55
+ ...n,
56
+ durationMs: Math.max(30, Math.round(n.durationMs * (i % 2 === 0 ? 1 + amt : 1 - amt))),
57
+ }));
58
+ }
59
+ function mutDeviate(sequence, mutate, deviate) {
60
+ const mutateChance = clamp(mutate, 0, 100) / 100;
61
+ const dev = clamp(deviate, 0, 100) / 100;
62
+ return sequence.map((n) => {
63
+ let pitch = n.pitch;
64
+ let velocity = n.velocity;
65
+ let durationMs = n.durationMs;
66
+ if (Math.random() < mutateChance) {
67
+ pitch += Math.round((Math.random() * 2 - 1) * (2 + dev * 8));
68
+ velocity += Math.round((Math.random() * 2 - 1) * (6 + dev * 18));
69
+ durationMs += Math.round((Math.random() * 2 - 1) * (25 + dev * 180));
70
+ }
71
+ return {
72
+ pitch: clamp(pitch, 0, 127),
73
+ velocity: clamp(velocity, 1, 127),
74
+ durationMs: clamp(durationMs, 35, 5000),
75
+ };
76
+ });
77
+ }
78
+ export function transformMelody(sequence, pitch, mod, backing) {
79
+ const [minP, maxP] = rangeBounds(pitch.range);
80
+ const gMul = gateMultiplier(backing.gate);
81
+ const depth = clamp(mod.depth, 0, 100) / 100;
82
+ const base = sequence.map((n, i, arr) => {
83
+ let p = clamp(n.pitch + clamp(pitch.transpose, -12, 12), minP, maxP);
84
+ if (pitch.snapScale)
85
+ p = closestScalePitch(p);
86
+ let v = n.velocity;
87
+ let d = Math.max(35, Math.round(n.durationMs * gMul));
88
+ const modv = lfo(i, arr.length, mod.rate) * depth;
89
+ if (mod.target === 'pitch')
90
+ p = clamp(Math.round(p + modv * 2.5), minP, maxP);
91
+ if (mod.target === 'velocity')
92
+ v = clamp(Math.round(v + modv * 22), 1, 127);
93
+ if (mod.target === 'duration')
94
+ d = clamp(Math.round(d * (1 + modv * 0.35)), 35, 6000);
95
+ return { pitch: p, velocity: v, durationMs: d };
96
+ });
97
+ const swung = swingify(base, backing.swing);
98
+ return mutDeviate(swung, backing.mutate, backing.deviate);
99
+ }
100
+ export function applyGrowthAndDuration(sequence, growth, durationStretch) {
101
+ const stretch = clamp(durationStretch, 1, 4);
102
+ if (growth === 'flat' && Math.abs(stretch - 1) < 0.001)
103
+ return sequence;
104
+ return sequence.map((n, i, arr) => {
105
+ const progress = arr.length <= 1 ? 1 : i / (arr.length - 1);
106
+ const complexity = Math.pow(progress, 1.35);
107
+ let pitch = n.pitch;
108
+ let velocity = n.velocity;
109
+ let durationMs = Math.round(n.durationMs * stretch);
110
+ if (growth === 'build') {
111
+ velocity += Math.round(complexity * 20);
112
+ durationMs = Math.round(durationMs * (0.95 + complexity * 0.35));
113
+ if (complexity > 0.55 && i % 5 === 4)
114
+ pitch += 1;
115
+ if (complexity > 0.78 && i % 7 === 6)
116
+ pitch += 1;
117
+ }
118
+ return {
119
+ pitch: clamp(pitch, 0, 127),
120
+ velocity: clamp(velocity, 1, 127),
121
+ durationMs: clamp(durationMs, 35, 8000),
122
+ };
123
+ });
124
+ }
125
+ export function sequenceToEvents(sequence, channel) {
126
+ const out = [];
127
+ let t = 0;
128
+ for (const n of sequence) {
129
+ out.push({
130
+ pitch: clamp(Math.round(n.pitch), 0, 127),
131
+ velocity: clamp(Math.round(n.velocity), 1, 127),
132
+ durationMs: Math.max(35, Math.round(n.durationMs)),
133
+ startMs: t,
134
+ channel: clamp(channel, 0, 15),
135
+ });
136
+ t += Math.max(35, Math.round(n.durationMs));
137
+ }
138
+ return out;
139
+ }
140
+ export function applyTimingFeel(events, bpm, feel, amount) {
141
+ if (feel === 'tight' || amount <= 0 || events.length === 0)
142
+ return events;
143
+ const beatMs = 60000 / Math.max(1, bpm);
144
+ const clampedAmount = clamp(amount, 0, 100) / 100;
145
+ const maxShiftMs = feel === 'human'
146
+ ? beatMs * 0.05 * clampedAmount
147
+ : feel === 'offbeat'
148
+ ? beatMs * 0.18 * clampedAmount
149
+ : beatMs * 0.12 * clampedAmount;
150
+ const shifted = events.map((e, i) => {
151
+ if (e.channel === 9 && feel === 'offbeat') {
152
+ // Keep downbeat kicks in place; move hats/perc around them.
153
+ const isKick = e.pitch === 36 || e.pitch === 35;
154
+ if (isKick)
155
+ return { ...e };
156
+ }
157
+ let delta = 0;
158
+ if (feel === 'human') {
159
+ delta = (Math.random() * 2 - 1) * maxShiftMs;
160
+ }
161
+ else if (feel === 'offbeat') {
162
+ const dir = i % 2 === 0 ? 1 : -1;
163
+ delta = dir * maxShiftMs;
164
+ }
165
+ else {
166
+ // loose: mixed push/pull with slightly stronger random.
167
+ const dir = i % 3 === 0 ? 1 : -1;
168
+ delta = dir * maxShiftMs * 0.6 + (Math.random() * 2 - 1) * maxShiftMs * 0.6;
169
+ }
170
+ return {
171
+ ...e,
172
+ startMs: Math.max(0, Math.round(e.startMs + delta)),
173
+ };
174
+ });
175
+ return shifted.sort((a, b) => a.startMs - b.startMs);
176
+ }
177
+ function pickStyle(theme) {
178
+ const t = theme.toLowerCase();
179
+ if (t.includes('techno') || t.includes('house'))
180
+ return 'techno';
181
+ if (t.includes('trap') || t.includes('hip'))
182
+ return 'trap';
183
+ if (t.includes('ambient') || t.includes('cinematic'))
184
+ return 'ambient';
185
+ return 'other';
186
+ }
187
+ export function buildBackingEvents(melodyEvents, theme, bpm, controls, growth = 'flat') {
188
+ if (!controls.drums && !controls.bass && !controls.clap && !controls.openHat && !controls.perc)
189
+ return [];
190
+ const out = [];
191
+ const style = pickStyle(theme);
192
+ const beatMs = 60000 / Math.max(1, bpm);
193
+ const endMs = melodyEvents.length
194
+ ? Math.max(...melodyEvents.map((e) => e.startMs + e.durationMs))
195
+ : beatMs * 8;
196
+ const bars = Math.max(1, Math.ceil(endMs / (beatMs * 4)));
197
+ if (controls.drums) {
198
+ for (let bar = 0; bar < bars; bar++) {
199
+ const buildProgress = bars <= 1 ? 1 : bar / (bars - 1);
200
+ const allowSnare = growth === 'build' ? buildProgress > 0.28 : true;
201
+ const allowHat = growth === 'build' ? buildProgress > 0.52 : true;
202
+ const allowPerc = growth === 'build' ? buildProgress > 0.65 : true;
203
+ const barStart = bar * beatMs * 4;
204
+ for (let step = 0; step < 16; step++) {
205
+ const stepMs = beatMs / 4;
206
+ const t = barStart + step * stepMs;
207
+ const beat = step % 4 === 0;
208
+ const off = step % 2 === 0;
209
+ if (style === 'techno') {
210
+ if (beat)
211
+ out.push({ pitch: 36, velocity: 104, durationMs: 90, startMs: t, channel: 9 }); // kick
212
+ if (allowSnare && step % 4 === 2)
213
+ out.push({ pitch: 38, velocity: 95, durationMs: 90, startMs: t, channel: 9 }); // snare
214
+ if (allowHat && off)
215
+ out.push({ pitch: 42, velocity: 92, durationMs: 45, startMs: t, channel: 9 }); // hihat
216
+ if (controls.clap && allowSnare && step % 8 === 4)
217
+ out.push({ pitch: 39, velocity: 84, durationMs: 80, startMs: t, channel: 9 }); // clap
218
+ if (controls.openHat && allowHat && step % 8 === 6)
219
+ out.push({ pitch: 46, velocity: 80, durationMs: 110, startMs: t, channel: 9 }); // open hat
220
+ if (controls.perc && allowPerc && step % 16 === 11)
221
+ out.push({ pitch: 45, velocity: 76, durationMs: 90, startMs: t, channel: 9 }); // perc tom
222
+ }
223
+ else if (style === 'trap') {
224
+ if (step === 0 || step === 10)
225
+ out.push({ pitch: 36, velocity: 105, durationMs: 90, startMs: t, channel: 9 });
226
+ if (allowSnare && (step === 4 || step === 12))
227
+ out.push({ pitch: 38, velocity: 95, durationMs: 90, startMs: t, channel: 9 });
228
+ if (allowHat && step % 2 === 0)
229
+ out.push({ pitch: 42, velocity: 88, durationMs: 35, startMs: t, channel: 9 });
230
+ if (controls.clap && allowSnare && (step === 4 || step === 12))
231
+ out.push({ pitch: 39, velocity: 80, durationMs: 70, startMs: t, channel: 9 });
232
+ if (controls.openHat && allowHat && (step === 7 || step === 15))
233
+ out.push({ pitch: 46, velocity: 78, durationMs: 95, startMs: t, channel: 9 });
234
+ if (controls.perc && allowPerc && step % 16 === 14)
235
+ out.push({ pitch: 50, velocity: 72, durationMs: 90, startMs: t, channel: 9 });
236
+ }
237
+ else if (style === 'ambient') {
238
+ if (step === 0)
239
+ out.push({ pitch: 36, velocity: 70, durationMs: 120, startMs: t, channel: 9 });
240
+ if (allowSnare && step === 8)
241
+ out.push({ pitch: 38, velocity: 64, durationMs: 120, startMs: t, channel: 9 });
242
+ if (allowHat && step % 4 === 0)
243
+ out.push({ pitch: 42, velocity: 70, durationMs: 40, startMs: t, channel: 9 });
244
+ if (controls.clap && allowSnare && step === 12)
245
+ out.push({ pitch: 39, velocity: 56, durationMs: 90, startMs: t, channel: 9 });
246
+ if (controls.openHat && allowHat && step === 10)
247
+ out.push({ pitch: 46, velocity: 62, durationMs: 110, startMs: t, channel: 9 });
248
+ if (controls.perc && allowPerc && step === 14)
249
+ out.push({ pitch: 75, velocity: 52, durationMs: 80, startMs: t, channel: 9 });
250
+ }
251
+ else {
252
+ if (beat)
253
+ out.push({ pitch: 36, velocity: 98, durationMs: 90, startMs: t, channel: 9 });
254
+ if (allowSnare && step % 8 === 4)
255
+ out.push({ pitch: 38, velocity: 88, durationMs: 90, startMs: t, channel: 9 });
256
+ if (allowHat && off)
257
+ out.push({ pitch: 42, velocity: 84, durationMs: 45, startMs: t, channel: 9 });
258
+ if (controls.clap && allowSnare && step % 8 === 4)
259
+ out.push({ pitch: 39, velocity: 76, durationMs: 80, startMs: t, channel: 9 });
260
+ if (controls.openHat && allowHat && step % 8 === 6)
261
+ out.push({ pitch: 46, velocity: 74, durationMs: 100, startMs: t, channel: 9 });
262
+ if (controls.perc && allowPerc && step % 16 === 13)
263
+ out.push({ pitch: 50, velocity: 70, durationMs: 85, startMs: t, channel: 9 });
264
+ }
265
+ }
266
+ }
267
+ }
268
+ if (controls.bass) {
269
+ for (const e of melodyEvents) {
270
+ if (e.startMs % Math.round(beatMs * 2) !== 0)
271
+ continue;
272
+ out.push({
273
+ pitch: clamp(e.pitch - 24, 28, 60),
274
+ velocity: clamp(e.velocity - 10, 45, 110),
275
+ durationMs: clamp(Math.round(e.durationMs * 1.2), 80, 600),
276
+ startMs: e.startMs,
277
+ channel: 1,
278
+ });
279
+ }
280
+ }
281
+ return out.sort((a, b) => a.startMs - b.startMs);
282
+ }