scribbletune 5.4.0 → 5.5.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 (3) hide show
  1. package/README.md +276 -22
  2. package/dist/cli.cjs +813 -0
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -1,41 +1,295 @@
1
- ## <img width=2% src="https://scribbletune.com/images/scribbletune-logo.png"> SCRIBBLETUNE
1
+ <p align="center">
2
+ <img width="64" src="https://scribbletune.com/images/scribbletune-logo.png" alt="Scribbletune">
3
+ </p>
2
4
 
3
- [![Build Status](https://travis-ci.com/scribbletune/scribbletune.svg?branch=master)](http://travis-ci.com/scribbletune/scribbletune)
4
- [![Try scribbletune on RunKit](https://badge.runkitcdn.com/scribbletune.svg)](https://npm.runkit.com/scribbletune)
5
+ <h1 align="center">Scribbletune</h1>
5
6
 
6
- Use simple **JavaScript** `Strings` and `Arrays` to generate rhythms and musical patterns. Directly use the names of scales or chords in your code to get arrays which you can mash up using Array methods in ways you hadn't imagined before! Create clips of musical ideas and **export MIDI files** which you can import in _Ableton Live, Reason, GarageBand_ or any music creation software that accepts MIDI files.
7
+ <p align="center">
8
+ Create music with JavaScript. Use simple strings and arrays to craft rhythms, melodies, and chord progressions — then export MIDI files or play them live in the browser with <a href="https://tonejs.github.io/">Tone.js</a>.
9
+ </p>
7
10
 
8
- ### Install
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/scribbletune"><img src="https://img.shields.io/npm/v/scribbletune.svg" alt="npm version"></a>
13
+ <a href="https://www.npmjs.com/package/scribbletune"><img src="https://img.shields.io/npm/l/scribbletune.svg" alt="license"></a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install scribbletune
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ### CLI
27
+
28
+ The package now ships a CLI binary: `scribbletune`.
29
+
30
+ Run modes:
9
31
 
10
32
  ```bash
33
+ # Global install
34
+ npm install -g scribbletune
35
+ scribbletune --help
36
+
37
+ # Local/project install
11
38
  npm install scribbletune
39
+ npx scribbletune --help
40
+ ```
41
+
42
+ #### Command format
43
+
44
+ ```bash
45
+ scribbletune --riff <root> <mode> <pattern> [octaveShift] [motif] [options]
46
+ scribbletune --chord <root> <mode> <progression|random> <pattern> [subdiv] [options]
47
+ scribbletune --arp <root> <mode> <progression|random> <pattern> [subdiv] [options]
48
+ ```
49
+
50
+ Common options:
51
+
52
+ ```bash
53
+ --outfile <file.mid> # default: music.mid
54
+ --bpm <number>
55
+ --subdiv <4n|8n|1m...>
56
+ --sizzle [sin|cos|rampUp|rampDown] [reps]
57
+ --sizzle-reps <number>
58
+ --amp <0-127>
59
+ --accent <x--x...>
60
+ --accent-low <0-127>
61
+ ```
62
+
63
+ #### `--riff` examples
64
+
65
+ ```bash
66
+ # Basic riff from scale
67
+ scribbletune --riff C3 phrygian x-xRx_RR --outfile riff.mid
68
+
69
+ # With octave shift and motif
70
+ scribbletune --riff C3 phrygian x-xRx_RR 0 AABC --sizzle sin 2 --outfile riff-aabc.mid
12
71
  ```
13
72
 
14
- ### Use it to create a MIDI clip by running a JS file from your terminal using node.js
15
- ```javascript
16
- const scribble = require('scribbletune');
17
- const clip = scribble.clip({
18
- notes: scribble.scale('C4 major'),
19
- pattern: 'x'.repeat(7) + '_'
73
+ #### `--chord` examples
74
+
75
+ ```bash
76
+ # Degree digits (resolved against root/mode)
77
+ scribbletune --chord C3 major 1645 xxxx 1m --sizzle cos 1 --outfile chords-1645.mid
78
+
79
+ # Roman numerals (space/comma separated)
80
+ scribbletune --chord C3 major "I IV vi V" xxxx 1m --outfile chords-roman.mid
81
+
82
+ # Random progression
83
+ scribbletune --chord C3 major random xxxx 1m --outfile chords-random.mid
84
+
85
+ # Explicit chord names (root/mode currently ignored for this style)
86
+ scribbletune --chord C3 major CM-FM-Am-GM xxxx 1m --outfile chords-explicit.mid
87
+ ```
88
+
89
+ #### `--arp` examples
90
+
91
+ ```bash
92
+ # Arp from degree progression
93
+ scribbletune --arp C3 major 1736 xxxx 1m --sizzle cos 4 --outfile arp-1736.mid
94
+
95
+ # Arp from explicit chords
96
+ scribbletune --arp C3 major CM-FM-Am-GM xxxx 1m --count 4 --order 0123 --outfile arp-explicit.mid
97
+ ```
98
+
99
+ Run `scribbletune --help` to see the latest CLI usage text.
100
+
101
+ ### Generate a MIDI file (Node.js)
102
+
103
+ ```js
104
+ import { scale, clip, midi } from 'scribbletune';
105
+
106
+ const notes = scale('C4 major');
107
+ const c = clip({ notes, pattern: 'x'.repeat(8) });
108
+
109
+ midi(c, 'c-major.mid');
110
+ ```
111
+
112
+ Run it with `node` and open the `.mid` file in Ableton Live, GarageBand, Logic, or any DAW.
113
+
114
+ ### Play in the browser (with Tone.js)
115
+
116
+ Scribbletune's browser entry point adds `Session`, `Channel`, and live `clip()` support on top of [Tone.js](https://tonejs.github.io/).
117
+
118
+ ```js
119
+ import { Session } from 'scribbletune/browser';
120
+
121
+ const session = new Session();
122
+ const channel = session.createChannel({
123
+ instrument: 'PolySynth',
124
+ clips: [
125
+ { pattern: 'x-x-', notes: 'C4 E4 G4' },
126
+ { pattern: '[-xx]', notes: 'C4 D#4' },
127
+ ],
20
128
  });
21
129
 
22
- scribble.midi(clip, 'c-major.mid');
130
+ await Tone.start();
131
+ Tone.Transport.start();
132
+ channel.startClip(0);
23
133
  ```
24
134
 
135
+ ### Standalone sample clip (no Session/Channel needed)
136
+
137
+ ```js
138
+ import { clip } from 'scribbletune/browser';
25
139
 
26
- You can use Scribbletune even **in the browser** with Tone.js!. There are a couple of ways to do this. A quick and dirty way is to make sure to pull in [Tone.js](https://cdnjs.com/libraries/tone) followed by the [latest browser version of Scribbletune](https://cdnjs.com/libraries/scribbletune).
140
+ await Tone.start();
141
+ Tone.Transport.start();
27
142
 
28
- ```html
29
- <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/<LATEST-VERSION-FROM-CDNJS>/Tone.min.js"></script>
30
- <script src="https://cdnjs.cloudflare.com/ajax/libs/scribbletune/<LATEST-VERSION-FROM-CDNJS>/scribbletune.js"></script>
143
+ const kick = clip({
144
+ sample: 'https://scribbletune.com/sounds/kick.wav',
145
+ pattern: 'x-x-',
146
+ });
147
+ kick.start();
31
148
  ```
32
149
 
33
- This will expose a global object called `scribble` which you can directly use to run the methods from Scribbletune in conjunction with Tone.js
150
+ ## Core concepts
34
151
 
35
- The recommended way for the browser, however, is to import it like this
36
- ```javascript
37
- const scribble = require('scribbletune/browser');
152
+ ### Pattern language
153
+
154
+ Scribbletune uses a simple string notation to describe rhythms:
155
+
156
+ | Char | Meaning |
157
+ |------|---------|
158
+ | `x` | Note on |
159
+ | `-` | Note off (rest) |
160
+ | `_` | Sustain previous note |
161
+ | `R` | Random note (from `randomNotes` pool) |
162
+ | `[]` | Subdivide (e.g. `[xx]` = two notes in one beat) |
163
+
164
+ ```js
165
+ 'x---x---x-x-x---' // basic kick pattern
166
+ '[xx][xx]x-x-' // hihat with subdivisions
167
+ 'x___' // one long sustained note
168
+ ```
169
+
170
+ ### Scales and chords
171
+
172
+ Powered by [harmonics](https://github.com/scribbletune/harmonics):
173
+
174
+ ```js
175
+ import { scale, chord, scales, chords } from 'scribbletune';
176
+
177
+ scale('C4 major'); // ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4']
178
+ chord('CM'); // ['C4', 'E4', 'G4']
179
+ scales(); // list all available scale names
180
+ chords(); // list all available chord names
181
+ ```
182
+
183
+ ### Arpeggios
184
+
185
+ ```js
186
+ import { arp } from 'scribbletune';
187
+
188
+ arp({ chords: 'CM FM', count: 4, order: '0123' });
189
+ // ['C4', 'E4', 'G4', 'C5', 'F4', 'A4', 'C5', 'F5']
38
190
  ```
39
- This will provide the same API but augment it a bit to support browser based functionality.
40
191
 
41
- Visit [scribbletune.com](https://scribbletune.com) for documentation, tutorials and examples! Listen to music generated with Scribbletune on [Soundcloud](https://soundcloud.com/scribbletune).
192
+ ### Chord progressions
193
+
194
+ ```js
195
+ import { progression, getChordsByProgression } from 'scribbletune';
196
+
197
+ progression('M', 4); // e.g. ['I', 'ii', 'V', 'IV']
198
+
199
+ getChordsByProgression('C4 major', 'I IV V IV');
200
+ // 'CM_4 FM_4 GM_4 FM_4'
201
+ ```
202
+
203
+ ## Browser API
204
+
205
+ The browser entry point (`scribbletune/browser`) provides everything above plus:
206
+
207
+ ### Session and Channel
208
+
209
+ ```js
210
+ import { Session } from 'scribbletune/browser';
211
+
212
+ const session = new Session();
213
+ const drums = session.createChannel({
214
+ sample: 'https://scribbletune.com/sounds/kick.wav',
215
+ clips: [
216
+ { pattern: 'x---x---' },
217
+ { pattern: 'x-x-x-x-' },
218
+ ],
219
+ });
220
+
221
+ const synth = session.createChannel({
222
+ instrument: 'PolySynth',
223
+ clips: [
224
+ { pattern: 'x-x-', notes: 'C4 E4 G4' },
225
+ ],
226
+ });
227
+
228
+ await Tone.start();
229
+ Tone.Transport.start();
230
+
231
+ // Start clips independently
232
+ drums.startClip(0);
233
+ synth.startClip(0);
234
+
235
+ // Switch patterns on the fly
236
+ drums.startClip(1);
237
+
238
+ // Or start a row across all channels
239
+ session.startRow(0);
240
+ ```
241
+
242
+ ### Channel options
243
+
244
+ Channels accept various sound sources:
245
+
246
+ ```js
247
+ // Built-in Tone.js synth (by name)
248
+ { instrument: 'PolySynth' }
249
+
250
+ // Pre-built Tone.js instrument
251
+ { instrument: new Tone.FMSynth() }
252
+
253
+ // Audio sample URL
254
+ { sample: 'https://example.com/kick.wav' }
255
+
256
+ // Multi-sample instrument
257
+ { samples: { C3: 'piano-c3.wav', D3: 'piano-d3.wav' } }
258
+
259
+ // With effects
260
+ { instrument: 'PolySynth', effects: ['Chorus', 'Reverb'] }
261
+ ```
262
+
263
+ ## API reference
264
+
265
+ | Export | Description |
266
+ |--------|-------------|
267
+ | `clip(params)` | Create a clip — returns note objects (Node.js) or a Tone.Sequence (browser) |
268
+ | `midi(clip, filename?)` | Export a clip to a MIDI file |
269
+ | `scale(name)` | Get notes of a scale, e.g. `'C4 minor'` |
270
+ | `chord(name)` | Get notes of a chord, e.g. `'CM'` |
271
+ | `scales()` | List all available scale names |
272
+ | `chords()` | List all available chord names |
273
+ | `arp(params)` | Generate arpeggiated note sequences |
274
+ | `progression(type, count)` | Generate a chord progression (`'M'` or `'m'`) |
275
+ | `getChordsByProgression(scale, degrees)` | Convert Roman numeral degrees to chord names |
276
+ | `getChordDegrees(mode)` | Get Roman numeral degrees for a mode |
277
+ | `Session` | _(browser only)_ Manage multiple channels and coordinate playback |
278
+
279
+ ## Development
280
+
281
+ ```bash
282
+ npm install # install dependencies
283
+ npm test # run tests
284
+ npm run build # build with tsup
285
+ npm run lint # check with biome
286
+ npm run dev # build in watch mode
287
+ ```
288
+
289
+ ## License
290
+
291
+ MIT
292
+
293
+ ---
294
+
295
+ [scribbletune.com](https://scribbletune.com) | [Soundcloud](https://soundcloud.com/scribbletune)
package/dist/cli.cjs ADDED
@@ -0,0 +1,813 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/cli.ts
32
+ var cli_exports = {};
33
+ __export(cli_exports, {
34
+ runCli: () => runCli
35
+ });
36
+ module.exports = __toCommonJS(cli_exports);
37
+ var import_harmonics4 = require("harmonics");
38
+
39
+ // src/arp.ts
40
+ var import_harmonics2 = require("harmonics");
41
+
42
+ // src/utils.ts
43
+ var import_harmonics = require("harmonics");
44
+ var isNote = (str) => /^[a-gA-G](?:#|b)?\d$/.test(str);
45
+ var expandStr = (str) => {
46
+ str = JSON.stringify(str.split(""));
47
+ str = str.replace(/,"\[",/g, ", [");
48
+ str = str.replace(/"\[",/g, "[");
49
+ str = str.replace(/,"\]"/g, "]");
50
+ return JSON.parse(str);
51
+ };
52
+ var shuffle = (arr, fullShuffle = true) => {
53
+ const lastIndex = arr.length - 1;
54
+ arr.forEach((el, idx) => {
55
+ if (idx >= lastIndex) {
56
+ return;
57
+ }
58
+ const rnd = fullShuffle ? (
59
+ // Pick random number from idx+1 to lastIndex (Modified algorithm, (N-1)! combinations)
60
+ // Math.random -> [0, 1) -> [0, lastIndex-idx ) --floor-> [0, lastIndex-idx-1]
61
+ // rnd = [0, lastIndex-idx-1] + 1 + idx = [1 + idx, lastIndex]
62
+ // (Original algorithm would pick rnd = [idx, lastIndex], thus any element could arrive back into its slot)
63
+ Math.floor(Math.random() * (lastIndex - idx)) + 1 + idx
64
+ ) : (
65
+ // Pick random number from idx to lastIndex (Unmodified Richard Durstenfeld, N! combinations)
66
+ Math.floor(Math.random() * (lastIndex + 1 - idx)) + idx
67
+ );
68
+ arr[idx] = arr[rnd];
69
+ arr[rnd] = el;
70
+ });
71
+ return arr;
72
+ };
73
+ var pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)];
74
+ var dice = () => !!Math.round(Math.random());
75
+ var errorHasMessage = (x) => {
76
+ return typeof x === "object" && x !== null && "message" in x && typeof x.message === "string";
77
+ };
78
+ var convertChordToNotes = (el) => {
79
+ let c1;
80
+ let c2;
81
+ let e1;
82
+ let e2;
83
+ try {
84
+ c1 = (0, import_harmonics.inlineChord)(el);
85
+ } catch (e) {
86
+ e1 = e;
87
+ }
88
+ try {
89
+ c2 = (0, import_harmonics.chord)(el.replace(/_/g, " "));
90
+ } catch (e) {
91
+ e2 = e;
92
+ }
93
+ if (!e1 && !e2) {
94
+ if (c1.toString() !== c2.toString()) {
95
+ throw new Error(`Chord ${el} cannot decode, guessing ${c1} or ${c2}`);
96
+ }
97
+ return c1;
98
+ }
99
+ if (!e1) {
100
+ return c1;
101
+ }
102
+ if (!e2) {
103
+ return c2;
104
+ }
105
+ return (0, import_harmonics.chord)(el);
106
+ };
107
+ var convertChordsToNotes = (el) => {
108
+ if (typeof el === "string" && isNote(el)) {
109
+ return [el];
110
+ }
111
+ if (Array.isArray(el)) {
112
+ el.forEach((n) => {
113
+ if (Array.isArray(n)) {
114
+ n.forEach((n1) => {
115
+ if (typeof n1 !== "string" || !isNote(n1)) {
116
+ throw new TypeError("array of arrays must comprise valid notes");
117
+ }
118
+ });
119
+ } else if (typeof n !== "string" || !isNote(n)) {
120
+ throw new TypeError("array must comprise valid notes");
121
+ }
122
+ });
123
+ return el;
124
+ }
125
+ if (!Array.isArray(el)) {
126
+ const c = convertChordToNotes(el);
127
+ if (c?.length) {
128
+ return c;
129
+ }
130
+ }
131
+ throw new Error(`Chord ${el} not found`);
132
+ };
133
+ var randomInt = (num = 1) => Math.round(Math.random() * num);
134
+
135
+ // src/arp.ts
136
+ var DEFAULT_OCTAVE = 4;
137
+ var fillArr = (arr, len) => {
138
+ const bumpOctave = (el) => {
139
+ if (!el) {
140
+ throw new Error("Empty element");
141
+ }
142
+ const note = el.replace(/\d/, "");
143
+ const oct = el.replace(/\D/g, "") || DEFAULT_OCTAVE;
144
+ if (!note) {
145
+ throw new Error("Incorrect note");
146
+ }
147
+ return note + (+oct + 1);
148
+ };
149
+ const arr1 = arr.map(bumpOctave);
150
+ const arr2 = arr1.map(bumpOctave);
151
+ const finalArr = [...arr, ...arr1, ...arr2];
152
+ return finalArr.slice(0, len);
153
+ };
154
+ var arp = (chordsOrParams) => {
155
+ let finalArr = [];
156
+ const params = {
157
+ count: 4,
158
+ order: "0123",
159
+ chords: ""
160
+ };
161
+ if (typeof chordsOrParams === "string") {
162
+ params.chords = chordsOrParams;
163
+ } else {
164
+ if (chordsOrParams.order?.match(/\D/g)) {
165
+ throw new TypeError("Invalid value for order");
166
+ }
167
+ if (chordsOrParams.count > 8 || chordsOrParams.count < 2) {
168
+ throw new TypeError("Invalid value for count");
169
+ }
170
+ if (chordsOrParams.count && !chordsOrParams.order) {
171
+ params.order = Array.from(Array(chordsOrParams.count).keys()).join("");
172
+ }
173
+ Object.assign(params, chordsOrParams);
174
+ }
175
+ if (typeof params.chords === "string") {
176
+ const chordsArr = params.chords.split(" ");
177
+ chordsArr.forEach((c, i) => {
178
+ try {
179
+ const filledArr = fillArr((0, import_harmonics2.inlineChord)(c), params.count);
180
+ const reorderedArr = params.order.split("").map((idx) => filledArr[Number(idx)]);
181
+ finalArr = [...finalArr, ...reorderedArr];
182
+ } catch (_e) {
183
+ throw new Error(
184
+ `Cannot decode chord ${i + 1} "${c}" in given "${params.chords}"`
185
+ );
186
+ }
187
+ });
188
+ } else if (Array.isArray(params.chords)) {
189
+ params.chords.forEach((c, i) => {
190
+ try {
191
+ const filledArr = fillArr(c, params.count);
192
+ const reorderedArr = params.order.split("").map((idx) => filledArr[Number(idx)]);
193
+ finalArr = [...finalArr, ...reorderedArr];
194
+ } catch (e) {
195
+ throw new Error(
196
+ `${errorHasMessage(e) ? e.message : e} in chord ${i + 1} "${c}"`
197
+ );
198
+ }
199
+ });
200
+ } else {
201
+ throw new TypeError("Invalid value for chords");
202
+ }
203
+ return finalArr;
204
+ };
205
+
206
+ // src/clip-utils.ts
207
+ var defaultParams = {
208
+ notes: ["C4"],
209
+ pattern: "x",
210
+ shuffle: false,
211
+ sizzle: false,
212
+ sizzleReps: 1,
213
+ arpegiate: false,
214
+ subdiv: "4n",
215
+ amp: 100,
216
+ accentLow: 70,
217
+ randomNotes: null,
218
+ offlineRendering: false
219
+ };
220
+ var validatePattern = (pattern) => {
221
+ if (/[^x\-_[\]R]/.test(pattern)) {
222
+ throw new TypeError(
223
+ `pattern can only comprise x - _ [ ] R, found ${pattern}`
224
+ );
225
+ }
226
+ };
227
+ var preprocessClipParams = (params, extraDefaults) => {
228
+ params = { ...defaultParams, ...extraDefaults, ...params || {} };
229
+ if (typeof params.notes === "string") {
230
+ params.notes = params.notes.replace(/\s{2,}/g, " ").split(" ");
231
+ }
232
+ params.notes = params.notes ? params.notes.map(convertChordsToNotes) : [];
233
+ validatePattern(params.pattern);
234
+ if (params.shuffle) {
235
+ params.notes = shuffle(params.notes);
236
+ }
237
+ if (params.randomNotes && typeof params.randomNotes === "string") {
238
+ params.randomNotes = params.randomNotes.replace(/\s{2,}/g, " ").split(/\s/);
239
+ }
240
+ if (params.randomNotes) {
241
+ params.randomNotes = params.randomNotes.map(
242
+ convertChordsToNotes
243
+ );
244
+ }
245
+ return params;
246
+ };
247
+
248
+ // src/clip.ts
249
+ var hdr = {
250
+ "1m": 2048,
251
+ "2m": 4096,
252
+ "3m": 6144,
253
+ "4m": 8192,
254
+ "1n": 512,
255
+ "2n": 256,
256
+ "4n": 128,
257
+ "8n": 64,
258
+ "16n": 32
259
+ };
260
+ var clip = (params) => {
261
+ params = preprocessClipParams(params);
262
+ const clipNotes = [];
263
+ let step = 0;
264
+ const recursivelyApplyPatternToNotes = (patternArr, length, parentNoteLength) => {
265
+ let totalLength = 0;
266
+ patternArr.forEach((char, idx) => {
267
+ if (typeof char === "string") {
268
+ let note = null;
269
+ if (char === "-") {
270
+ } else if (char === "R" && randomInt() && // Use 1/2 probability for R to pick from param.notes
271
+ params.randomNotes && params.randomNotes.length > 0) {
272
+ note = params.randomNotes[randomInt(params.randomNotes.length - 1)];
273
+ } else if (params.notes) {
274
+ note = params.notes[step];
275
+ }
276
+ if (char === "x" || char === "R") {
277
+ step++;
278
+ }
279
+ if (char === "x" || char === "-" || char === "R") {
280
+ clipNotes.push({
281
+ note,
282
+ length,
283
+ level: char === "R" && !params.randomNotes ? params.accentLow : params.amp
284
+ });
285
+ totalLength += length;
286
+ }
287
+ if (char === "_" && clipNotes.length) {
288
+ clipNotes[clipNotes.length - 1].length += length;
289
+ totalLength += length;
290
+ }
291
+ if (parentNoteLength && totalLength !== parentNoteLength && idx === patternArr.length - 1) {
292
+ const diff = Math.abs(
293
+ parentNoteLength - totalLength
294
+ );
295
+ const lastClipNote = clipNotes[clipNotes.length - 1];
296
+ if (lastClipNote.length > diff) {
297
+ lastClipNote.length = lastClipNote.length - diff;
298
+ } else {
299
+ lastClipNote.length = lastClipNote.length + diff;
300
+ }
301
+ }
302
+ if (step === params.notes?.length) {
303
+ step = 0;
304
+ }
305
+ }
306
+ if (Array.isArray(char)) {
307
+ let isTriplet = false;
308
+ if (char.length % 2 !== 0 || length % 2 !== 0) {
309
+ isTriplet = true;
310
+ }
311
+ recursivelyApplyPatternToNotes(
312
+ char,
313
+ Math.round(length / char.length),
314
+ isTriplet && length
315
+ );
316
+ totalLength += length;
317
+ }
318
+ });
319
+ };
320
+ recursivelyApplyPatternToNotes(
321
+ expandStr(params.pattern),
322
+ hdr[params.subdiv] || hdr["4n"],
323
+ false
324
+ );
325
+ if (params.sizzle) {
326
+ const volArr = [];
327
+ const style = params.sizzle === true ? "sin" : params.sizzle;
328
+ const beats = clipNotes.length;
329
+ const amp = params.amp;
330
+ const sizzleReps = params.sizzleReps;
331
+ const stepLevel = amp / (beats / sizzleReps);
332
+ if (style === "sin" || style === "cos") {
333
+ for (let i = 0; i < beats; i++) {
334
+ const level = Math[style](i * Math.PI / (beats / sizzleReps)) * amp;
335
+ volArr.push(Math.round(Math.abs(level)));
336
+ }
337
+ }
338
+ if (style === "rampUp") {
339
+ let level = 0;
340
+ for (let i = 0; i < beats; i++) {
341
+ if (i % (beats / sizzleReps) === 0) {
342
+ level = 0;
343
+ } else {
344
+ level = level + stepLevel;
345
+ }
346
+ volArr.push(Math.round(Math.abs(level)));
347
+ }
348
+ }
349
+ if (style === "rampDown") {
350
+ let level = amp;
351
+ for (let i = 0; i < beats; i++) {
352
+ if (i % (beats / sizzleReps) === 0) {
353
+ level = amp;
354
+ } else {
355
+ level = level - stepLevel;
356
+ }
357
+ volArr.push(Math.round(Math.abs(level)));
358
+ }
359
+ }
360
+ for (let i = 0; i < volArr.length; i++) {
361
+ clipNotes[i].level = volArr[i] ? volArr[i] : 1;
362
+ }
363
+ }
364
+ if (params.accent) {
365
+ if (/[^x-]/.test(params.accent)) {
366
+ throw new TypeError("Accent can only have x and - characters");
367
+ }
368
+ let a = 0;
369
+ for (const clipNote of clipNotes) {
370
+ let level = params.accent[a] === "x" ? params.amp : params.accentLow;
371
+ if (params.sizzle) {
372
+ level = (clipNote.level + level) / 2;
373
+ }
374
+ clipNote.level = Math.round(level);
375
+ a = a + 1;
376
+ if (a === params.accent.length) {
377
+ a = 0;
378
+ }
379
+ }
380
+ }
381
+ return clipNotes;
382
+ };
383
+
384
+ // src/midi.ts
385
+ var import_node_fs = __toESM(require("fs"), 1);
386
+ var import_midi = require("@scribbletune/midi");
387
+ var midi = (notes, fileName = "music.mid", bpm) => {
388
+ const file = createFileFromNotes(notes, bpm);
389
+ const bytes = file.toBytes();
390
+ if (fileName === null) {
391
+ return bytes;
392
+ }
393
+ if (!fileName.endsWith(".mid")) {
394
+ fileName = `${fileName}.mid`;
395
+ }
396
+ if (typeof window !== "undefined" && window.URL && typeof window.URL.createObjectURL === "function") {
397
+ return createDownloadLink(bytes, fileName);
398
+ }
399
+ import_node_fs.default.writeFileSync(fileName, bytes, "binary");
400
+ console.log(`MIDI file generated: ${fileName}.`);
401
+ };
402
+ var createDownloadLink = (b, fileName) => {
403
+ const bytes = new Uint8Array(b.length);
404
+ for (let i = 0; i < b.length; i++) {
405
+ const ascii = b.charCodeAt(i);
406
+ bytes[i] = ascii;
407
+ }
408
+ const blob = new Blob([bytes], { type: "audio/midi" });
409
+ const link = document.createElement("a");
410
+ link.href = typeof window !== "undefined" && typeof window.URL !== "undefined" && typeof window.URL.createObjectURL !== "undefined" && window.URL.createObjectURL(blob) || "";
411
+ link.download = fileName;
412
+ link.innerText = "Download MIDI file";
413
+ return link;
414
+ };
415
+ var createFileFromNotes = (notes, bpm) => {
416
+ const file = new import_midi.File();
417
+ const track = new import_midi.Track();
418
+ if (typeof bpm === "number") {
419
+ track.setTempo(bpm);
420
+ }
421
+ file.addTrack(track);
422
+ for (const noteObj of notes) {
423
+ const level = noteObj.level || 127;
424
+ if (noteObj.note) {
425
+ if (typeof noteObj.note === "string") {
426
+ track.noteOn(0, noteObj.note, noteObj.length, level);
427
+ track.noteOff(0, noteObj.note, noteObj.length, level);
428
+ } else {
429
+ track.addChord(0, noteObj.note, noteObj.length, level);
430
+ }
431
+ } else {
432
+ track.noteOff(0, "", noteObj.length);
433
+ }
434
+ }
435
+ return file;
436
+ };
437
+
438
+ // src/progression.ts
439
+ var import_harmonics3 = require("harmonics");
440
+ var getChordDegrees = (mode) => {
441
+ const theRomans = {
442
+ ionian: ["I", "ii", "iii", "IV", "V", "vi", "vii\xB0"],
443
+ dorian: ["i", "ii", "III", "IV", "v", "vi\xB0", "VII"],
444
+ phrygian: ["i", "II", "III", "iv", "v\xB0", "VI", "vii"],
445
+ lydian: ["I", "II", "iii", "iv\xB0", "V", "vi", "vii"],
446
+ mixolydian: ["I", "ii", "iii\xB0", "IV", "v", "vi", "VII"],
447
+ aeolian: ["i", "ii\xB0", "III", "iv", "v", "VI", "VII"],
448
+ locrian: ["i\xB0", "II", "iii", "iv", "V", "VI", "vii"],
449
+ "melodic minor": ["i", "ii", "III+", "IV", "V", "vi\xB0", "vii\xB0"],
450
+ "harmonic minor": ["i", "ii\xB0", "III+", "iv", "V", "VI", "vii\xB0"]
451
+ };
452
+ theRomans.major = theRomans.ionian;
453
+ theRomans.minor = theRomans.aeolian;
454
+ return theRomans[mode] || [];
455
+ };
456
+ var idxByDegree = {
457
+ i: 0,
458
+ ii: 1,
459
+ iii: 2,
460
+ iv: 3,
461
+ v: 4,
462
+ vi: 5,
463
+ vii: 6
464
+ };
465
+ var getChordName = (roman) => {
466
+ const str = roman.replace(/\W/g, "");
467
+ let prefix = "M";
468
+ if (str.toLowerCase() === str) {
469
+ prefix = "m";
470
+ }
471
+ if (roman.indexOf("\xB0") > -1) {
472
+ return `${prefix}7b5`;
473
+ }
474
+ if (roman.indexOf("+") > -1) {
475
+ return `${prefix}#5`;
476
+ }
477
+ if (roman.indexOf("7") > -1) {
478
+ return prefix === "M" ? "maj7" : "m7";
479
+ }
480
+ return prefix;
481
+ };
482
+ var getChordsByProgression = (noteOctaveScale, chordDegress) => {
483
+ const noteOctaveScaleArr = noteOctaveScale.split(" ");
484
+ if (!noteOctaveScaleArr[0].match(/\d/)) {
485
+ noteOctaveScaleArr[0] += "4";
486
+ noteOctaveScale = noteOctaveScaleArr.join(" ");
487
+ }
488
+ const mode = (0, import_harmonics3.scale)(noteOctaveScale);
489
+ const chordDegreesArr = chordDegress.replace(/\s*,+\s*/g, " ").split(" ");
490
+ const chordFamily = chordDegreesArr.map((roman) => {
491
+ const chordName = getChordName(roman);
492
+ const scaleId = idxByDegree[roman.replace(/\W|\d/g, "").toLowerCase()];
493
+ const note = mode[scaleId];
494
+ const oct = note.replace(/\D+/, "");
495
+ return `${note.replace(/\d/, "") + chordName}_${oct}`;
496
+ });
497
+ return chordFamily.toString().replace(/,/g, " ");
498
+ };
499
+ var getProgFactory = ({ T, P, D }) => {
500
+ return (count = 4) => {
501
+ const chords = [];
502
+ chords.push(pickOne(T));
503
+ let i = 1;
504
+ if (i < count - 1) {
505
+ chords.push(pickOne(P));
506
+ i++;
507
+ }
508
+ if (i < count - 1 && dice()) {
509
+ chords.push(pickOne(P));
510
+ i++;
511
+ }
512
+ if (i < count - 1) {
513
+ chords.push(pickOne(D));
514
+ i++;
515
+ }
516
+ if (i < count - 1) {
517
+ chords.push(pickOne(P));
518
+ i++;
519
+ }
520
+ if (i < count - 1) {
521
+ chords.push(pickOne(D));
522
+ i++;
523
+ }
524
+ if (i < count - 1 && dice()) {
525
+ chords.push(pickOne(P));
526
+ i++;
527
+ }
528
+ while (i < count) {
529
+ chords.push(pickOne(D));
530
+ i++;
531
+ }
532
+ return chords;
533
+ };
534
+ };
535
+ var M = getProgFactory({ T: ["I", "vi"], P: ["ii", "IV"], D: ["V"] });
536
+ var m = getProgFactory({ T: ["i", "VI"], P: ["ii", "iv"], D: ["V"] });
537
+ var progression = (scaleType, count = 4) => {
538
+ if (scaleType === "major" || scaleType === "M") {
539
+ return M(count);
540
+ }
541
+ if (scaleType === "minor" || scaleType === "m") {
542
+ return m(count);
543
+ }
544
+ return [];
545
+ };
546
+
547
+ // src/cli.ts
548
+ var HELP_TEXT = `Usage:
549
+ scribbletune --riff <root> <mode> <pattern> [octaveShift] [motif]
550
+ scribbletune --chord <root> <mode> <progression|random> <pattern> [subdiv]
551
+ scribbletune --arp <root> <mode> <progression|random> <pattern> [subdiv]
552
+
553
+ Examples:
554
+ scribbletune --riff C3 phrygian x-xRx_RR 0 AABC --sizzle sin 2 --outfile riff.mid
555
+ scribbletune --chord C3 major 1645 xxxx 1m --sizzle cos 1 --outfile chord.mid
556
+ scribbletune --chord C3 major CM-FM-Am-GM xxxx 1m
557
+ scribbletune --chord C3 major random xxxx 1m
558
+ scribbletune --arp C3 major 1736 xxxx 1m --sizzle cos 4
559
+
560
+ Options:
561
+ --outfile <name> Output MIDI filename (default: music.mid)
562
+ --bpm <number> Tempo in BPM
563
+ --subdiv <value> Note subdivision (e.g. 4n, 1m)
564
+ --sizzle [style] [n] Sizzle style: sin|cos|rampUp|rampDown and optional reps
565
+ --sizzle-reps <n> Repetitions for sizzle
566
+ --amp <0-127> Maximum note level
567
+ --accent <pattern> Accent pattern using x and -
568
+ --accent-low <0-127> Accent low level
569
+ --count <2-8> Arp note count (arp command only)
570
+ --order <digits> Arp order string (arp command only)
571
+ -h, --help Show this help
572
+ `;
573
+ var romanByDigit = (progDigits, mode) => {
574
+ const modeForDegrees = mode.toLowerCase();
575
+ const chordDegrees = getChordDegrees(modeForDegrees);
576
+ if (!chordDegrees.length) {
577
+ throw new TypeError(`Unsupported mode "${mode}" for progression digits`);
578
+ }
579
+ const romans = progDigits.split("").map((digit) => {
580
+ const idx = Number(digit) - 1;
581
+ if (idx < 0 || idx >= chordDegrees.length) {
582
+ throw new TypeError(`Invalid progression digit "${digit}" in "${progDigits}"`);
583
+ }
584
+ return chordDegrees[idx];
585
+ });
586
+ return { chordDegrees: romans.join(" "), raw: progDigits };
587
+ };
588
+ var setOctave = (note, octaveShift = 0) => {
589
+ const base = note.replace(/\d+/g, "");
590
+ const oct = Number(note.match(/\d+/)?.[0] || "4");
591
+ return `${base}${oct + octaveShift}`;
592
+ };
593
+ var parseProgression = (root, mode, progressionInput) => {
594
+ if (progressionInput === "random") {
595
+ const modeType = mode === "minor" || mode === "m" ? "minor" : "major";
596
+ const randomProg = progression(modeType, 4).join(" ");
597
+ return getChordsByProgression(`${root} ${mode}`, randomProg);
598
+ }
599
+ if (/^[1-7]+$/.test(progressionInput)) {
600
+ const converted = romanByDigit(progressionInput, mode);
601
+ return getChordsByProgression(`${root} ${mode}`, converted.chordDegrees);
602
+ }
603
+ if (/^[ivIV°+7\s,]+$/.test(progressionInput)) {
604
+ const normalized = progressionInput.replace(/\s*,+\s*/g, " ");
605
+ return getChordsByProgression(`${root} ${mode}`, normalized);
606
+ }
607
+ return progressionInput.replace(/-/g, " ");
608
+ };
609
+ var parseCliArgs = (argv) => {
610
+ if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
611
+ return null;
612
+ }
613
+ let commandArg = argv[0];
614
+ if (commandArg.startsWith("--")) {
615
+ commandArg = commandArg.slice(2);
616
+ }
617
+ if (commandArg !== "riff" && commandArg !== "chord" && commandArg !== "arp") {
618
+ throw new TypeError(
619
+ `First argument must be riff/chord/arp (or --riff/--chord/--arp), received "${argv[0]}"`
620
+ );
621
+ }
622
+ const positionals = [];
623
+ const options = {
624
+ command: commandArg,
625
+ positionals,
626
+ outfile: "music.mid"
627
+ };
628
+ let i = 1;
629
+ while (i < argv.length) {
630
+ const token = argv[i];
631
+ if (!token.startsWith("--")) {
632
+ positionals.push(token);
633
+ i++;
634
+ continue;
635
+ }
636
+ if (token === "--outfile") {
637
+ options.outfile = argv[i + 1];
638
+ i += 2;
639
+ continue;
640
+ }
641
+ if (token === "--bpm") {
642
+ options.bpm = Number(argv[i + 1]);
643
+ i += 2;
644
+ continue;
645
+ }
646
+ if (token === "--subdiv") {
647
+ options.subdiv = argv[i + 1];
648
+ i += 2;
649
+ continue;
650
+ }
651
+ if (token === "--sizzle") {
652
+ const styleOrNum = argv[i + 1];
653
+ const maybeNum = argv[i + 2];
654
+ if (!styleOrNum || styleOrNum.startsWith("--")) {
655
+ options.sizzle = true;
656
+ i += 1;
657
+ continue;
658
+ }
659
+ if (/^\d+$/.test(styleOrNum)) {
660
+ options.sizzle = true;
661
+ options.sizzleReps = Number(styleOrNum);
662
+ i += 2;
663
+ continue;
664
+ }
665
+ options.sizzle = styleOrNum;
666
+ if (maybeNum && /^\d+$/.test(maybeNum)) {
667
+ options.sizzleReps = Number(maybeNum);
668
+ i += 3;
669
+ } else {
670
+ i += 2;
671
+ }
672
+ continue;
673
+ }
674
+ if (token === "--sizzle-reps") {
675
+ options.sizzleReps = Number(argv[i + 1]);
676
+ i += 2;
677
+ continue;
678
+ }
679
+ if (token === "--amp") {
680
+ options.amp = Number(argv[i + 1]);
681
+ i += 2;
682
+ continue;
683
+ }
684
+ if (token === "--accent") {
685
+ options.accent = argv[i + 1];
686
+ i += 2;
687
+ continue;
688
+ }
689
+ if (token === "--accent-low") {
690
+ options.accentLow = Number(argv[i + 1]);
691
+ i += 2;
692
+ continue;
693
+ }
694
+ if (token === "--count") {
695
+ options.count = Number(argv[i + 1]);
696
+ i += 2;
697
+ continue;
698
+ }
699
+ if (token === "--order") {
700
+ options.order = argv[i + 1];
701
+ i += 2;
702
+ continue;
703
+ }
704
+ throw new TypeError(`Unknown option "${token}"`);
705
+ }
706
+ return options;
707
+ };
708
+ var baseClipParams = (parsed) => {
709
+ return {
710
+ sizzle: parsed.sizzle,
711
+ sizzleReps: parsed.sizzleReps,
712
+ amp: parsed.amp,
713
+ accent: parsed.accent,
714
+ accentLow: parsed.accentLow,
715
+ subdiv: parsed.subdiv
716
+ };
717
+ };
718
+ var makeRiff = (parsed) => {
719
+ const [root, mode, pattern, octaveShiftArg, motif] = parsed.positionals;
720
+ if (!root || !mode || !pattern) {
721
+ throw new TypeError(
722
+ "riff requires: <root> <mode> <pattern> [octaveShift] [motif]"
723
+ );
724
+ }
725
+ const octaveShift = Number(octaveShiftArg || "0");
726
+ const riffScale = (0, import_harmonics4.scale)(`${setOctave(root, octaveShift)} ${mode}`);
727
+ const riffNotes = motif && motif.length ? motif.toUpperCase().split("").map((letter) => {
728
+ const idx = letter.charCodeAt(0) - 65;
729
+ if (idx < 0) {
730
+ return riffScale[0];
731
+ }
732
+ return riffScale[idx % riffScale.length];
733
+ }) : riffScale;
734
+ return clip({
735
+ notes: riffNotes,
736
+ randomNotes: riffScale,
737
+ pattern,
738
+ ...baseClipParams(parsed)
739
+ });
740
+ };
741
+ var makeChord = (parsed) => {
742
+ const [root, mode, progressionInput, pattern, subdiv] = parsed.positionals;
743
+ if (!root || !mode || !progressionInput || !pattern) {
744
+ throw new TypeError(
745
+ "chord requires: <root> <mode> <progression|random> <pattern> [subdiv]"
746
+ );
747
+ }
748
+ const chords = parseProgression(root, mode, progressionInput);
749
+ return clip({
750
+ notes: chords,
751
+ pattern,
752
+ subdiv: parsed.subdiv || subdiv,
753
+ ...baseClipParams(parsed)
754
+ });
755
+ };
756
+ var makeArp = (parsed) => {
757
+ const [root, mode, progressionInput, pattern, subdiv] = parsed.positionals;
758
+ if (!root || !mode || !progressionInput || !pattern) {
759
+ throw new TypeError(
760
+ "arp requires: <root> <mode> <progression|random> <pattern> [subdiv]"
761
+ );
762
+ }
763
+ const chords = parseProgression(root, mode, progressionInput);
764
+ const arpNotes = arp({
765
+ chords,
766
+ count: parsed.count || 4,
767
+ order: parsed.order || "0123"
768
+ });
769
+ return clip({
770
+ notes: arpNotes,
771
+ pattern,
772
+ subdiv: parsed.subdiv || subdiv,
773
+ ...baseClipParams(parsed)
774
+ });
775
+ };
776
+ var runCli = (argv, deps) => {
777
+ const stdout = deps?.stdout || console.log;
778
+ const stderr = deps?.stderr || console.error;
779
+ const writeMidi = deps?.writeMidi || ((notes, fileName, bpm) => {
780
+ midi(notes, fileName, bpm);
781
+ });
782
+ try {
783
+ const parsed = parseCliArgs(argv);
784
+ if (!parsed) {
785
+ stdout(HELP_TEXT);
786
+ return 0;
787
+ }
788
+ let notes = [];
789
+ if (parsed.command === "riff") {
790
+ notes = makeRiff(parsed);
791
+ } else if (parsed.command === "chord") {
792
+ notes = makeChord(parsed);
793
+ } else {
794
+ notes = makeArp(parsed);
795
+ }
796
+ writeMidi(notes, parsed.outfile, parsed.bpm);
797
+ stdout(
798
+ `Generated ${parsed.command} clip (${notes.length} events) -> ${parsed.outfile}`
799
+ );
800
+ return 0;
801
+ } catch (e) {
802
+ stderr(e instanceof Error ? e.message : String(e));
803
+ stderr("Run with --help for usage");
804
+ return 1;
805
+ }
806
+ };
807
+ if (process.argv[1]?.includes("cli")) {
808
+ process.exit(runCli(process.argv.slice(2)));
809
+ }
810
+ // Annotate the CommonJS export names for ESM import in node:
811
+ 0 && (module.exports = {
812
+ runCli
813
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scribbletune",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Create music with JavaScript and Node.js!",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -9,6 +9,9 @@
9
9
  "dist"
10
10
  ],
11
11
  "module": "./dist/index.js",
12
+ "bin": {
13
+ "scribbletune": "./dist/cli.cjs"
14
+ },
12
15
  "exports": {
13
16
  ".": {
14
17
  "types": "./dist/index.d.ts",