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
@@ -0,0 +1,499 @@
1
+ import { access, mkdir } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { writeFile } from 'node:fs/promises';
5
+ import type { NoteEvent } from './arrangement';
6
+ import type { FxSettings } from './fx';
7
+ import type { GeneratedNote } from './types';
8
+
9
+ const SAMPLE_RATE = 44100;
10
+
11
+ function pitchToFrequency(pitch: number): number {
12
+ return 440 * Math.pow(2, (pitch - 69) / 12);
13
+ }
14
+
15
+ async function ensureParent(path: string): Promise<void> {
16
+ await mkdir(dirname(path), { recursive: true });
17
+ }
18
+
19
+ function i16(n: number): number {
20
+ return Math.max(-32768, Math.min(32767, Math.round(n)));
21
+ }
22
+
23
+ function waveSample(type: FxSettings['waveform'], phase: number): number {
24
+ const s = Math.sin(phase);
25
+ if (type === 'sine') return s;
26
+ if (type === 'square') return s >= 0 ? 1 : -1;
27
+ // saw, normalized [-1..1]
28
+ return 2 * ((phase / (2 * Math.PI)) % 1) - 1;
29
+ }
30
+
31
+ function drumSample(pitch: number, i: number, count: number): number {
32
+ const t = i / SAMPLE_RATE;
33
+ const n = Math.random() * 2 - 1;
34
+
35
+ // Kick
36
+ if (pitch === 35 || pitch === 36) {
37
+ const f = 120 * Math.exp(-t * 14) + 42;
38
+ const phase = 2 * Math.PI * f * t;
39
+ const env = Math.exp(-t * 8);
40
+ return Math.sin(phase) * env;
41
+ }
42
+
43
+ // Snare / clap-ish
44
+ if (pitch === 38 || pitch === 40) {
45
+ const tone = Math.sin(2 * Math.PI * 210 * t) * Math.exp(-t * 14) * 0.35;
46
+ const noise = n * Math.exp(-t * 18) * 0.85;
47
+ return tone + noise;
48
+ }
49
+
50
+ // Closed/open hats
51
+ if (pitch === 42 || pitch === 44 || pitch === 46) {
52
+ const bright = n - (i > 0 ? Math.sin(2 * Math.PI * 1800 * ((i - 1) / SAMPLE_RATE)) * 0.2 : 0);
53
+ const env = pitch === 46 ? Math.exp(-t * 36) : Math.exp(-t * 58);
54
+ return bright * env;
55
+ }
56
+
57
+ // Generic percussion fallback
58
+ const env = Math.exp((-28 * i) / Math.max(1, count));
59
+ return n * env;
60
+ }
61
+
62
+ function applyPostFx(samples: Float32Array, fx: FxSettings): Float32Array {
63
+ const out = new Float32Array(samples.length);
64
+ const driveGain = 1 + fx.drive * 14;
65
+
66
+ // bitcrush: quantize + hold
67
+ const bitDepth = Math.max(4, Math.round(16 - fx.bitcrush * 10));
68
+ const levels = Math.pow(2, bitDepth - 1);
69
+ const holdEvery = Math.max(1, Math.round(1 + fx.bitcrush * 14));
70
+ let held = 0;
71
+
72
+ for (let i = 0; i < samples.length; i++) {
73
+ if (i % holdEvery === 0) {
74
+ const driven = Math.tanh(samples[i] * driveGain);
75
+ held = Math.round(driven * levels) / levels;
76
+ }
77
+ out[i] = held;
78
+ }
79
+
80
+ const wetMix = Math.max(0, Math.min(1, fx.reverb));
81
+ if (wetMix <= 0.001) return out;
82
+
83
+ // simple feedback delay as lightweight reverb flavor
84
+ const delaySamples = Math.max(1, Math.round(SAMPLE_RATE * (0.12 + 0.2 * wetMix)));
85
+ const feedback = 0.2 + 0.45 * wetMix;
86
+ const wet = new Float32Array(out);
87
+
88
+ for (let i = delaySamples; i < wet.length; i++) {
89
+ wet[i] += wet[i - delaySamples] * feedback;
90
+ }
91
+
92
+ const mixed = new Float32Array(out.length);
93
+ for (let i = 0; i < mixed.length; i++) {
94
+ const m = out[i] * (1 - wetMix) + wet[i] * wetMix;
95
+ mixed[i] = Math.max(-1, Math.min(1, m));
96
+ }
97
+ return mixed;
98
+ }
99
+
100
+ function buildWavPcm(sequence: GeneratedNote[] | NoteEvent[], fx: FxSettings): Buffer {
101
+ const events: NoteEvent[] = (sequence as NoteEvent[])[0]?.startMs != null
102
+ ? (sequence as NoteEvent[])
103
+ : (() => {
104
+ const out: NoteEvent[] = [];
105
+ let t = 0;
106
+ for (const n of sequence as GeneratedNote[]) {
107
+ out.push({ pitch: n.pitch, velocity: n.velocity, durationMs: n.durationMs, startMs: t, channel: 0 });
108
+ t += n.durationMs;
109
+ }
110
+ return out;
111
+ })();
112
+
113
+ const endMs = events.length ? Math.max(...events.map((e) => e.startMs + e.durationMs)) : 0;
114
+ const totalSamples = Math.max(1, Math.ceil((endMs / 1000) * SAMPLE_RATE));
115
+ const mix = new Float32Array(totalSamples);
116
+
117
+ for (const note of events) {
118
+ const isDrum = note.channel === 9;
119
+ const freq = isDrum ? 0 : pitchToFrequency(note.pitch);
120
+ const durationS = Math.max(0.02, note.durationMs / 1000);
121
+ const count = Math.max(1, Math.floor(SAMPLE_RATE * durationS));
122
+ const amp = (Math.max(1, Math.min(127, note.velocity)) / 127) * (isDrum ? 0.5 : 0.35);
123
+ const startSample = Math.max(0, Math.floor((note.startMs / 1000) * SAMPLE_RATE));
124
+
125
+ for (let i = 0; i < count; i++) {
126
+ const idx = startSample + i;
127
+ if (idx >= mix.length) break;
128
+ if (isDrum) {
129
+ mix[idx] += drumSample(note.pitch, i, count) * amp;
130
+ } else {
131
+ const t = i / SAMPLE_RATE;
132
+ const phase = 2 * Math.PI * freq * t;
133
+ const envDecay = 2 + (1 - fx.reverb) * 4;
134
+ const env = Math.exp((-envDecay * i) / count);
135
+ const osc = waveSample(fx.waveform, phase);
136
+ mix[idx] += osc * amp * env;
137
+ }
138
+ }
139
+ }
140
+
141
+ // normalize before post fx
142
+ let peak = 0;
143
+ for (let i = 0; i < mix.length; i++) peak = Math.max(peak, Math.abs(mix[i]));
144
+ if (peak > 1) {
145
+ const inv = 1 / peak;
146
+ for (let i = 0; i < mix.length; i++) mix[i] *= inv;
147
+ }
148
+
149
+ const floatSamples = mix;
150
+ const processed = applyPostFx(floatSamples, fx);
151
+
152
+ const pcm = new Int16Array(processed.length);
153
+ for (let i = 0; i < processed.length; i++) {
154
+ pcm[i] = i16(processed[i] * 32767);
155
+ }
156
+
157
+ const dataSize = pcm.length * 2;
158
+ const b = Buffer.alloc(44 + dataSize);
159
+ b.write('RIFF', 0);
160
+ b.writeUInt32LE(36 + dataSize, 4);
161
+ b.write('WAVE', 8);
162
+ b.write('fmt ', 12);
163
+ b.writeUInt32LE(16, 16);
164
+ b.writeUInt16LE(1, 20);
165
+ b.writeUInt16LE(1, 22);
166
+ b.writeUInt32LE(SAMPLE_RATE, 24);
167
+ b.writeUInt32LE(SAMPLE_RATE * 2, 28);
168
+ b.writeUInt16LE(2, 32);
169
+ b.writeUInt16LE(16, 34);
170
+ b.write('data', 36);
171
+ b.writeUInt32LE(dataSize, 40);
172
+
173
+ for (let i = 0; i < pcm.length; i++) {
174
+ b.writeInt16LE(pcm[i], 44 + i * 2);
175
+ }
176
+ return b;
177
+ }
178
+
179
+ async function getFfmpegBinary(): Promise<string> {
180
+ try {
181
+ const mod = await import('ffmpeg-static');
182
+ const path = mod.default;
183
+ if (path && typeof path === 'string') return path;
184
+ } catch {
185
+ // Fall back to system ffmpeg.
186
+ }
187
+ return 'ffmpeg';
188
+ }
189
+
190
+ async function runFfmpeg(args: string[]): Promise<void> {
191
+ const bin = await getFfmpegBinary();
192
+ return new Promise((resolvePromise, reject) => {
193
+ const p = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
194
+ let stderr = '';
195
+ if (p.stderr) {
196
+ p.stderr.on('data', (chunk: Buffer | string) => {
197
+ stderr += chunk.toString();
198
+ });
199
+ }
200
+ p.on('error', reject);
201
+ p.on('close', (code) => {
202
+ if (code === 0) resolvePromise();
203
+ else {
204
+ const tail = stderr.trim().split('\n').slice(-8).join('\n');
205
+ reject(new Error(`ffmpeg exited with code ${code ?? -1}${tail ? `\n${tail}` : ''}`));
206
+ }
207
+ });
208
+ });
209
+ }
210
+
211
+ export function isFfmpegMissing(err: unknown): boolean {
212
+ if (!err || typeof err !== 'object') return false;
213
+ const maybe = err as { code?: string };
214
+ return maybe.code === 'ENOENT';
215
+ }
216
+
217
+ export async function exportSequenceToWav(
218
+ sequence: GeneratedNote[] | NoteEvent[],
219
+ outPath: string,
220
+ fx: FxSettings,
221
+ ): Promise<string> {
222
+ const fullPath = resolve(outPath);
223
+ await ensureParent(fullPath);
224
+ await writeFile(fullPath, buildWavPcm(sequence, fx));
225
+ return fullPath;
226
+ }
227
+
228
+ export async function exportWavToMp3(wavPath: string, outPath: string): Promise<string> {
229
+ const fullPath = resolve(outPath);
230
+ await ensureParent(fullPath);
231
+ await runFfmpeg(['-y', '-i', resolve(wavPath), '-codec:a', 'libmp3lame', '-q:a', '2', fullPath]);
232
+ return fullPath;
233
+ }
234
+
235
+ export async function exportWavToMp4(wavPath: string, outPath: string, coverImagePath?: string): Promise<string> {
236
+ const fullPath = resolve(outPath);
237
+ await ensureParent(fullPath);
238
+
239
+ const audio = resolve(wavPath);
240
+ const image = coverImagePath?.trim();
241
+ let hasImage = false;
242
+ if (image) {
243
+ try {
244
+ await access(resolve(image));
245
+ hasImage = true;
246
+ } catch {
247
+ hasImage = false;
248
+ }
249
+ }
250
+ const args = image
251
+ ? [
252
+ '-y',
253
+ '-loop',
254
+ '1',
255
+ '-i',
256
+ resolve(image),
257
+ '-i',
258
+ audio,
259
+ '-c:v',
260
+ 'libx264',
261
+ '-tune',
262
+ 'stillimage',
263
+ '-c:a',
264
+ 'aac',
265
+ '-b:a',
266
+ '192k',
267
+ '-pix_fmt',
268
+ 'yuv420p',
269
+ '-shortest',
270
+ fullPath,
271
+ ]
272
+ : [
273
+ '-y',
274
+ '-f',
275
+ 'lavfi',
276
+ '-i',
277
+ 'color=c=#120a1a:s=1280x720:r=30',
278
+ '-i',
279
+ audio,
280
+ '-c:v',
281
+ 'libx264',
282
+ '-tune',
283
+ 'stillimage',
284
+ '-c:a',
285
+ 'aac',
286
+ '-b:a',
287
+ '192k',
288
+ '-pix_fmt',
289
+ 'yuv420p',
290
+ '-shortest',
291
+ fullPath,
292
+ ];
293
+
294
+ if (!hasImage) {
295
+ await runFfmpeg([
296
+ '-y',
297
+ '-f',
298
+ 'lavfi',
299
+ '-i',
300
+ 'color=c=#120a1a:s=1280x720:r=30',
301
+ '-i',
302
+ audio,
303
+ '-c:v',
304
+ 'libx264',
305
+ '-tune',
306
+ 'stillimage',
307
+ '-c:a',
308
+ 'aac',
309
+ '-b:a',
310
+ '192k',
311
+ '-pix_fmt',
312
+ 'yuv420p',
313
+ '-shortest',
314
+ fullPath,
315
+ ]);
316
+ return fullPath;
317
+ }
318
+
319
+ await runFfmpeg(args);
320
+ return fullPath;
321
+ }
322
+
323
+ function detectRecordInput(device?: string): string[] {
324
+ const d = (device ?? '').trim();
325
+ if (process.platform === 'darwin') {
326
+ // avfoundation uses "<video>:<audio>", so audio-only default is ":0".
327
+ const input = d ? (d.includes(':') ? d : `:${d}`) : ':0';
328
+ return ['-f', 'avfoundation', '-i', input];
329
+ }
330
+ if (process.platform === 'linux') {
331
+ const input = d || 'default';
332
+ return ['-f', 'pulse', '-i', input];
333
+ }
334
+ if (process.platform === 'win32') {
335
+ const input = d || 'audio=default';
336
+ return ['-f', 'dshow', '-i', input];
337
+ }
338
+ throw new Error(`Recording is not supported on platform: ${process.platform}`);
339
+ }
340
+
341
+ export async function recordInputToWav(outPath: string, seconds: number, device?: string): Promise<string> {
342
+ const fullPath = resolve(outPath);
343
+ await ensureParent(fullPath);
344
+ const duration = Math.max(1, Math.floor(seconds));
345
+ const inputArgs = detectRecordInput(device);
346
+ await runFfmpeg([
347
+ '-y',
348
+ ...inputArgs,
349
+ '-t',
350
+ String(duration),
351
+ '-ac',
352
+ '1',
353
+ '-ar',
354
+ String(SAMPLE_RATE),
355
+ fullPath,
356
+ ]);
357
+ return fullPath;
358
+ }
359
+
360
+ export async function processRecordedWav(
361
+ inPath: string,
362
+ outPath: string,
363
+ options?: {
364
+ eqMode?: 'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone';
365
+ profile?: 'default' | 'vinyl' | 'dust';
366
+ family?: 'character' | 'motion' | 'space' | 'bug';
367
+ bugMode?: 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch';
368
+ intensity?: number;
369
+ chaos?: number;
370
+ mix?: number;
371
+ scratch?: 'off' | 'texture' | 'dj';
372
+ wavy?: number;
373
+ },
374
+ ): Promise<string> {
375
+ const fullPath = resolve(outPath);
376
+ await ensureParent(fullPath);
377
+ const eqMode = options?.eqMode ?? 'balanced';
378
+ const profile = options?.profile ?? 'default';
379
+ const family = options?.family ?? 'character';
380
+ const bugMode = options?.bugMode ?? 'off';
381
+ const intensity = Math.max(0, Math.min(100, options?.intensity ?? 45)) / 100;
382
+ const chaos = Math.max(0, Math.min(100, options?.chaos ?? 25)) / 100;
383
+ const mix = Math.max(0, Math.min(100, options?.mix ?? 40)) / 100;
384
+ const fxAmt = Math.max(0, Math.min(1, intensity * (0.25 + mix * 0.75)));
385
+ const scratch = options?.scratch ?? 'off';
386
+ const wavyAmount = Math.max(0, Math.min(100, options?.wavy ?? 0));
387
+ const filters: string[] = [];
388
+
389
+ if (profile === 'vinyl') {
390
+ filters.push('highpass=f=110');
391
+ filters.push('lowpass=f=7200');
392
+ filters.push('acompressor=threshold=-20dB:ratio=2.2:attack=10:release=120');
393
+ filters.push('aecho=0.6:0.3:38:0.12');
394
+ } else if (profile === 'dust') {
395
+ filters.push('highpass=f=280');
396
+ filters.push('lowpass=f=3600');
397
+ filters.push('acompressor=threshold=-18dB:ratio=3.6:attack=12:release=150');
398
+ filters.push('acrusher=bits=7:mode=lin:mix=0.22');
399
+ }
400
+
401
+ if (eqMode === 'balanced') {
402
+ filters.push('highpass=f=35');
403
+ filters.push('lowpass=f=13000');
404
+ filters.push('equalizer=f=140:width_type=h:width=90:g=1.6');
405
+ filters.push('equalizer=f=3300:width_type=h:width=1800:g=1.1');
406
+ } else if (eqMode === 'warm') {
407
+ filters.push('lowpass=f=8400');
408
+ filters.push('equalizer=f=170:width_type=h:width=120:g=2.4');
409
+ filters.push('equalizer=f=2600:width_type=h:width=1300:g=-1.4');
410
+ } else if (eqMode === 'bright') {
411
+ filters.push('highpass=f=45');
412
+ filters.push('equalizer=f=4600:width_type=h:width=2200:g=2.2');
413
+ filters.push('equalizer=f=160:width_type=h:width=100:g=-1');
414
+ } else if (eqMode === 'bass') {
415
+ filters.push('equalizer=f=95:width_type=h:width=75:g=4');
416
+ filters.push('equalizer=f=3000:width_type=h:width=1800:g=-1.2');
417
+ } else if (eqMode === 'phone') {
418
+ filters.push('highpass=f=350');
419
+ filters.push('lowpass=f=3200');
420
+ filters.push('equalizer=f=1500:width_type=h:width=900:g=2.2');
421
+ }
422
+
423
+ if (family === 'character') {
424
+ const crushBits = Math.max(7, Math.round(16 - fxAmt * 7));
425
+ const crushMix = (0.06 + fxAmt * 0.18).toFixed(2);
426
+ filters.push(`acrusher=bits=${crushBits}:mode=lin:mix=${crushMix}`);
427
+ filters.push('acompressor=threshold=-18dB:ratio=2.2:attack=6:release=80');
428
+ } else if (family === 'motion') {
429
+ const tremDepth = (0.08 + fxAmt * 0.42).toFixed(2);
430
+ const vibDepth = Math.min(0.9, 0.05 + fxAmt * 0.6).toFixed(2);
431
+ const vibFreq = (2.5 + chaos * 5).toFixed(2);
432
+ filters.push(`tremolo=f=6:d=${tremDepth}`);
433
+ filters.push(`vibrato=f=${vibFreq}:d=${vibDepth}`);
434
+ } else if (family === 'space') {
435
+ const d1 = Math.round(40 + fxAmt * 120);
436
+ const d2 = Math.round(80 + fxAmt * 220);
437
+ const dec1 = (0.12 + fxAmt * 0.35).toFixed(2);
438
+ const dec2 = (0.08 + fxAmt * 0.24).toFixed(2);
439
+ filters.push(`aecho=0.7:0.45:${d1}|${d2}:${dec1}|${dec2}`);
440
+ filters.push('lowpass=f=8200');
441
+ } else if (family === 'bug' && bugMode !== 'off') {
442
+ if (bugMode === 'pll-drift') {
443
+ const vibFreq = (0.45 + chaos * 1.8).toFixed(2);
444
+ const vibDepth = Math.min(0.8, 0.08 + fxAmt * 0.5).toFixed(2);
445
+ filters.push(`vibrato=f=${vibFreq}:d=${vibDepth}`);
446
+ filters.push(`tremolo=f=${(3 + chaos * 6).toFixed(2)}:d=${(0.1 + fxAmt * 0.16).toFixed(2)}`);
447
+ } else if (bugMode === 'buffer-tear') {
448
+ const tempo = (1 + (chaos - 0.5) * 0.08).toFixed(3);
449
+ filters.push(`atempo=${tempo}`);
450
+ filters.push(`tremolo=f=${(8 + chaos * 14).toFixed(2)}:d=${(0.2 + fxAmt * 0.28).toFixed(2)}`);
451
+ } else if (bugMode === 'clock-bleed') {
452
+ const bits = Math.max(4, Math.round(12 - fxAmt * 6));
453
+ const crushMix = (0.22 + fxAmt * 0.5).toFixed(2);
454
+ filters.push(`acrusher=bits=${bits}:mode=lin:mix=${crushMix}`);
455
+ filters.push('highpass=f=260');
456
+ } else if (bugMode === 'memory-rot') {
457
+ const lp = Math.round(5200 - fxAmt * 2600);
458
+ const bits = Math.max(5, Math.round(10 - fxAmt * 4));
459
+ filters.push(`lowpass=f=${lp}`);
460
+ filters.push(`acrusher=bits=${bits}:mode=lin:mix=${(0.18 + fxAmt * 0.35).toFixed(2)}`);
461
+ } else if (bugMode === 'crc-glitch') {
462
+ filters.push(`tremolo=f=${(14 + chaos * 20).toFixed(2)}:d=${(0.28 + fxAmt * 0.24).toFixed(2)}`);
463
+ filters.push('highpass=f=900');
464
+ filters.push('lowpass=f=2400');
465
+ }
466
+ }
467
+
468
+ if (scratch === 'texture') {
469
+ filters.push('tremolo=f=9:d=0.35');
470
+ filters.push('highpass=f=420');
471
+ filters.push('lowpass=f=3200');
472
+ } else if (scratch === 'dj') {
473
+ filters.push('tremolo=f=13:d=0.78');
474
+ filters.push('asetrate=44100*1.04');
475
+ filters.push('aresample=44100');
476
+ filters.push('aecho=0.7:0.45:35|70:0.35|0.15');
477
+ filters.push('highpass=f=520');
478
+ filters.push('lowpass=f=2800');
479
+ }
480
+ if (wavyAmount > 0) {
481
+ const tremDepth = (0.08 + (wavyAmount / 100) * 0.42).toFixed(2);
482
+ // ffmpeg vibrato depth "d" must be in [0, 1].
483
+ const vibDepth = Math.min(0.95, 0.08 + (wavyAmount / 100) * 0.87).toFixed(2);
484
+ filters.push(`tremolo=f=4:d=${tremDepth}`);
485
+ filters.push(`vibrato=f=5:d=${vibDepth}`);
486
+ }
487
+
488
+ // Keep output audible when aggressive FX stacks are selected.
489
+ filters.push('acompressor=threshold=-16dB:ratio=2:attack=5:release=90:makeup=4');
490
+ filters.push('alimiter=limit=0.96');
491
+
492
+ const args = ['-y', '-i', resolve(inPath)];
493
+ if (filters.length > 0) {
494
+ args.push('-af', filters.join(','));
495
+ }
496
+ args.push(fullPath);
497
+ await runFfmpeg(args);
498
+ return fullPath;
499
+ }
@@ -0,0 +1,85 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ function u32be(n) {
4
+ const b = Buffer.alloc(4);
5
+ b.writeUInt32BE(n >>> 0, 0);
6
+ return b;
7
+ }
8
+ function u16be(n) {
9
+ const b = Buffer.alloc(2);
10
+ b.writeUInt16BE(n >>> 0, 0);
11
+ return b;
12
+ }
13
+ function varLen(n) {
14
+ let value = n >>> 0;
15
+ const out = [value & 0x7f];
16
+ value >>= 7;
17
+ while (value > 0) {
18
+ out.unshift((value & 0x7f) | 0x80);
19
+ value >>= 7;
20
+ }
21
+ return out;
22
+ }
23
+ function ticksFromMs(durationMs, bpm, tpqn) {
24
+ const msPerBeat = 60000 / bpm;
25
+ return Math.max(1, Math.round((durationMs / msPerBeat) * tpqn));
26
+ }
27
+ export async function exportSequenceToMidi(sequence, bpm, outPath) {
28
+ const tpqn = 480;
29
+ const events = [];
30
+ const tempoUsPerBeat = Math.max(1, Math.round(60_000_000 / bpm));
31
+ events.push(0x00, 0xff, 0x51, 0x03);
32
+ events.push((tempoUsPerBeat >> 16) & 0xff, (tempoUsPerBeat >> 8) & 0xff, tempoUsPerBeat & 0xff);
33
+ const asEvents = sequence[0]?.startMs != null
34
+ ? sequence
35
+ : (() => {
36
+ const out = [];
37
+ let t = 0;
38
+ for (const n of sequence) {
39
+ out.push({
40
+ pitch: n.pitch,
41
+ velocity: n.velocity,
42
+ durationMs: n.durationMs,
43
+ startMs: t,
44
+ channel: 0,
45
+ });
46
+ t += n.durationMs;
47
+ }
48
+ return out;
49
+ })();
50
+ const midiEvents = [];
51
+ for (const note of asEvents) {
52
+ const pitch = Math.max(0, Math.min(127, Math.round(note.pitch)));
53
+ const velocity = Math.max(1, Math.min(127, Math.round(note.velocity)));
54
+ const startTick = ticksFromMs(note.startMs, bpm, tpqn);
55
+ const durTick = ticksFromMs(note.durationMs, bpm, tpqn);
56
+ const ch = Math.max(0, Math.min(15, Math.round(note.channel ?? 0)));
57
+ midiEvents.push({ tick: startTick, bytes: [0x90 | ch, pitch, velocity] });
58
+ midiEvents.push({ tick: startTick + durTick, bytes: [0x80 | ch, pitch, 0x00] });
59
+ }
60
+ midiEvents.sort((a, b) => a.tick - b.tick);
61
+ let prevTick = 0;
62
+ for (const e of midiEvents) {
63
+ const delta = Math.max(0, e.tick - prevTick);
64
+ events.push(...varLen(delta), ...e.bytes);
65
+ prevTick = e.tick;
66
+ }
67
+ events.push(0x00, 0xff, 0x2f, 0x00);
68
+ const trackData = Buffer.from(events);
69
+ const header = Buffer.concat([
70
+ Buffer.from('MThd'),
71
+ u32be(6),
72
+ u16be(0),
73
+ u16be(1),
74
+ u16be(tpqn),
75
+ ]);
76
+ const track = Buffer.concat([
77
+ Buffer.from('MTrk'),
78
+ u32be(trackData.length),
79
+ trackData,
80
+ ]);
81
+ const fullPath = resolve(outPath);
82
+ await mkdir(dirname(fullPath), { recursive: true });
83
+ await writeFile(fullPath, Buffer.concat([header, track]));
84
+ return fullPath;
85
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import type { NoteEvent } from './arrangement';
4
+ import type { GeneratedNote } from './types';
5
+
6
+ function u32be(n: number): Buffer {
7
+ const b = Buffer.alloc(4);
8
+ b.writeUInt32BE(n >>> 0, 0);
9
+ return b;
10
+ }
11
+
12
+ function u16be(n: number): Buffer {
13
+ const b = Buffer.alloc(2);
14
+ b.writeUInt16BE(n >>> 0, 0);
15
+ return b;
16
+ }
17
+
18
+ function varLen(n: number): number[] {
19
+ let value = n >>> 0;
20
+ const out = [value & 0x7f];
21
+ value >>= 7;
22
+ while (value > 0) {
23
+ out.unshift((value & 0x7f) | 0x80);
24
+ value >>= 7;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function ticksFromMs(durationMs: number, bpm: number, tpqn: number): number {
30
+ const msPerBeat = 60000 / bpm;
31
+ return Math.max(1, Math.round((durationMs / msPerBeat) * tpqn));
32
+ }
33
+
34
+ export async function exportSequenceToMidi(
35
+ sequence: GeneratedNote[] | NoteEvent[],
36
+ bpm: number,
37
+ outPath: string,
38
+ ): Promise<string> {
39
+ const tpqn = 480;
40
+ const events: number[] = [];
41
+
42
+ const tempoUsPerBeat = Math.max(1, Math.round(60_000_000 / bpm));
43
+ events.push(0x00, 0xff, 0x51, 0x03);
44
+ events.push((tempoUsPerBeat >> 16) & 0xff, (tempoUsPerBeat >> 8) & 0xff, tempoUsPerBeat & 0xff);
45
+
46
+ const asEvents: NoteEvent[] = (sequence as NoteEvent[])[0]?.startMs != null
47
+ ? (sequence as NoteEvent[])
48
+ : (() => {
49
+ const out: NoteEvent[] = [];
50
+ let t = 0;
51
+ for (const n of sequence as GeneratedNote[]) {
52
+ out.push({
53
+ pitch: n.pitch,
54
+ velocity: n.velocity,
55
+ durationMs: n.durationMs,
56
+ startMs: t,
57
+ channel: 0,
58
+ });
59
+ t += n.durationMs;
60
+ }
61
+ return out;
62
+ })();
63
+
64
+ const midiEvents: Array<{ tick: number; bytes: number[] }> = [];
65
+ for (const note of asEvents) {
66
+ const pitch = Math.max(0, Math.min(127, Math.round(note.pitch)));
67
+ const velocity = Math.max(1, Math.min(127, Math.round(note.velocity)));
68
+ const startTick = ticksFromMs(note.startMs, bpm, tpqn);
69
+ const durTick = ticksFromMs(note.durationMs, bpm, tpqn);
70
+ const ch = Math.max(0, Math.min(15, Math.round(note.channel ?? 0)));
71
+ midiEvents.push({ tick: startTick, bytes: [0x90 | ch, pitch, velocity] });
72
+ midiEvents.push({ tick: startTick + durTick, bytes: [0x80 | ch, pitch, 0x00] });
73
+ }
74
+ midiEvents.sort((a, b) => a.tick - b.tick);
75
+ let prevTick = 0;
76
+ for (const e of midiEvents) {
77
+ const delta = Math.max(0, e.tick - prevTick);
78
+ events.push(...varLen(delta), ...e.bytes);
79
+ prevTick = e.tick;
80
+ }
81
+
82
+ events.push(0x00, 0xff, 0x2f, 0x00);
83
+ const trackData = Buffer.from(events);
84
+
85
+ const header = Buffer.concat([
86
+ Buffer.from('MThd'),
87
+ u32be(6),
88
+ u16be(0),
89
+ u16be(1),
90
+ u16be(tpqn),
91
+ ]);
92
+
93
+ const track = Buffer.concat([
94
+ Buffer.from('MTrk'),
95
+ u32be(trackData.length),
96
+ trackData,
97
+ ]);
98
+
99
+ const fullPath = resolve(outPath);
100
+ await mkdir(dirname(fullPath), { recursive: true });
101
+ await writeFile(fullPath, Buffer.concat([header, track]));
102
+ return fullPath;
103
+ }
@@ -0,0 +1,5 @@
1
+ declare module 'ffmpeg-static' {
2
+ const path: string | null;
3
+ export default path;
4
+ }
5
+