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.
- package/LICENSE +22 -0
- package/README.md +272 -0
- package/package.json +30 -0
- package/src/arrangement.js +282 -0
- package/src/arrangement.ts +317 -0
- package/src/assets/cover.png +0 -0
- package/src/cli.js +1398 -0
- package/src/cli.ts +1709 -0
- package/src/exportAudio.js +459 -0
- package/src/exportAudio.ts +499 -0
- package/src/exportMidi.js +85 -0
- package/src/exportMidi.ts +103 -0
- package/src/ffmpeg-static.d.ts +5 -0
- package/src/fx.js +28 -0
- package/src/fx.ts +49 -0
- package/src/generator.js +15 -0
- package/src/generator.ts +29 -0
- package/src/index.js +511 -0
- package/src/index.ts +642 -0
- package/src/instrument.js +35 -0
- package/src/instrument.ts +51 -0
- package/src/midi.js +167 -0
- package/src/midi.ts +218 -0
- package/src/openExport.js +22 -0
- package/src/openExport.ts +24 -0
- package/src/prompt.js +22 -0
- package/src/prompt.ts +25 -0
- package/src/providers/auth.js +23 -0
- package/src/providers/auth.ts +30 -0
- package/src/providers/claudeProvider.js +46 -0
- package/src/providers/claudeProvider.ts +50 -0
- package/src/providers/factory.js +39 -0
- package/src/providers/factory.ts +43 -0
- package/src/providers/geminiProvider.js +55 -0
- package/src/providers/geminiProvider.ts +71 -0
- package/src/providers/grokProvider.js +57 -0
- package/src/providers/grokProvider.ts +69 -0
- package/src/providers/groqProvider.js +57 -0
- package/src/providers/groqProvider.ts +69 -0
- package/src/providers/mockProvider.js +13 -0
- package/src/providers/mockProvider.ts +15 -0
- package/src/providers/openaiProvider.js +45 -0
- package/src/providers/openaiProvider.ts +49 -0
- package/src/providers/retry.js +46 -0
- package/src/providers/retry.ts +54 -0
- package/src/types.js +1 -0
- package/src/types.ts +17 -0
- package/src/validate.js +10 -0
- package/src/validate.ts +13 -0
- 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
|
+
}
|