spessoplayer 0.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.
package/main.mjs ADDED
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ Copyright (C) 2025 unixatch
4
+
5
+ it under the terms of the GNU General Public License as published by
6
+ This program is free software: you can redistribute it and/or modify
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with spessoplayer. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+
19
+ import { log } from "./utils.mjs"
20
+
21
+ addEvent({ eventType: "SIGINT" })
22
+ log(1, performance.now().toFixed(2), "Added SIGINT event")
23
+ // In case the user passes some arguments
24
+ const {
25
+ actUpOnPassedArgs,
26
+ } = await import("./cli.mjs");
27
+ log(1, performance.now().toFixed(2), "Checking passed args...")
28
+ await actUpOnPassedArgs(process.argv)
29
+
30
+
31
+ /**
32
+ * Simply returns an object containing ffmpeg's arguments in all supported formats
33
+ * @param {String} [outFile="pipe:1"] - file path to write to
34
+ * @return {Object} - available formats in Object format
35
+ */
36
+ function ffmpegArgs(outFile = "pipe:1") {
37
+ return {
38
+ flac: [
39
+ "-i", "-",
40
+ "-f", "flac",
41
+ "-compression_level", "12",
42
+ outFile
43
+ ],
44
+ mp3: [
45
+ "-i", "-",
46
+ "-f", "mp3",
47
+ "-aq", "0",
48
+ outFile
49
+ ]
50
+ };
51
+ }
52
+ let spawn,
53
+ spawnSync;
54
+ if (global?.toStdout) {
55
+ await toStdout(global?.loopN, global?.volume)
56
+ process.exit()
57
+ }
58
+ if (global?.fileOutputs?.length > 0) await toFile(global?.loopN, global?.volume)
59
+ await startPlayer(global?.loopN, global?.volume)
60
+
61
+ /**
62
+ * Calculates the sample count to use
63
+ * @param {class} midi - The BasicMIDI class to use
64
+ * @param {Number} sampleRate - The sample rate to use
65
+ * @param {Number} loopAmount - The amount of loops to do
66
+ */
67
+ function getSampleCount(midi, sampleRate, loopAmount) {
68
+ global.loopStart = global?.loopStart ?? midi.midiTicksToSeconds(midi.loop.start);
69
+ let loopDetectedInMidi = false;
70
+ if (midi.loop.start > 0) {
71
+ loopDetectedInMidi = true;
72
+ global.loopStart = midi.midiTicksToSeconds(midi.loop.start);
73
+ global.loopEnd = midi.midiTicksToSeconds(midi.loop.end);
74
+ }
75
+ const possibleLoopAmount = (loopAmount === 0) ? loopAmount+1 : loopAmount ?? 1;
76
+ let sampleCount;
77
+ if ((loopAmount ?? 0) === 0) {
78
+ sampleCount = Math.ceil(sampleRate * midi.duration);
79
+ } else {
80
+ let end;
81
+ if (global?.loopEnd === undefined && !loopDetectedInMidi) {
82
+ end = midi.duration;
83
+ } else if (global.loopEnd !== undefined && !loopDetectedInMidi) {
84
+ end = midi.duration - global.loopEnd;
85
+ } else end = global.loopEnd;
86
+
87
+ sampleCount = Math.ceil(
88
+ sampleRate *
89
+ (
90
+ midi.duration +
91
+ ((end - global.loopStart) * possibleLoopAmount)
92
+ )
93
+ );
94
+ }
95
+ log(1, performance.now().toFixed(2), "Sample count set to " + sampleCount)
96
+ return {
97
+ loopDetectedInMidi,
98
+ sampleCount
99
+ };
100
+ }
101
+ /**
102
+ * Initializes all the required variables for spessasynth_core usage
103
+ * @param {any} loopAmount - the loop amount
104
+ * @param {Number} [volume=100/100] - the volume to set
105
+ * @param {Boolean} [isToFile=false] - defines or not audioToWav
106
+ */
107
+ async function initSpessaSynth(loopAmount, volume = 100/100, isToFile = false) {
108
+ let audioToWav,
109
+ BasicMIDI,
110
+ SoundBankLoader,
111
+ SpessaSynthProcessor,
112
+ SpessaSynthSequencer;
113
+ if (isToFile) {
114
+ ({
115
+ audioToWav,
116
+ BasicMIDI,
117
+ SoundBankLoader,
118
+ SpessaSynthProcessor,
119
+ SpessaSynthSequencer
120
+ } = await import("spessasynth_core"))
121
+ } else {
122
+ ({
123
+ BasicMIDI,
124
+ SoundBankLoader,
125
+ SpessaSynthProcessor,
126
+ SpessaSynthSequencer
127
+ } = await import("spessasynth_core"))
128
+ }
129
+ const mid = fs.readFileSync(global.midiFile);
130
+ const sf = fs.readFileSync(global.soundfontFile);
131
+ const midi = BasicMIDI.fromArrayBuffer(mid);
132
+ const sampleRate = global?.sampleRate ?? 48000;
133
+ const {
134
+ sampleCount,
135
+ loopDetectedInMidi
136
+ } = getSampleCount(midi, sampleRate, loopAmount);
137
+
138
+ if (global.loopStart > 0 && !loopDetectedInMidi) {
139
+ // ((midi.timeDivision * midi.tempoChanges[0].tempo)/60) * global.loopStart;
140
+ midi.loop.start = midi.secondsToMIDITicks(global.loopStart);
141
+ }
142
+ if (global?.loopEnd && global.loopEnd !== midi.duration && !loopDetectedInMidi) {
143
+ // (midi.duration - global.loopEnd) * (midi.tempoChanges[1].tempo/60) * midi.timeDivision;
144
+ midi.loop.end = midi.secondsToMIDITicks(midi.duration - global.loopEnd);
145
+ }
146
+ const synth = new SpessaSynthProcessor(sampleRate, {
147
+ enableEventSystem: false,
148
+ enableEffects: false
149
+ });
150
+ synth.setMasterParameter("masterGain", volume)
151
+ synth.soundBankManager.addSoundBank(
152
+ SoundBankLoader.fromArrayBuffer(sf),
153
+ "main"
154
+ )
155
+ await synth.processorInitialized
156
+ const seq = new SpessaSynthSequencer(synth);
157
+ seq.loadNewSongList([midi])
158
+ seq.loopCount = loopAmount ?? 0;
159
+ seq.play();
160
+
161
+ addEvent({ eventType: "uncaughtException" })
162
+ log(1, performance.now().toFixed(2), "Finished setting up SpessaSynth")
163
+ return {
164
+ audioToWav,
165
+ seq, synth,
166
+ midi,
167
+ sampleCount, sampleRate
168
+ }
169
+ }
170
+ /**
171
+ * Applies effects using SoX
172
+ * @param {Object} obj - the object passed
173
+ * @param {Stream} obj.program - the process to spawn, sox usually
174
+ * @param {Stream} obj.stdoutHeader - the header to process
175
+ * @param {Stream} obj.readStream - the data to process
176
+ * @param {Stream} obj.stdout - the destination
177
+ * @param {String} obj.destination - the destination path
178
+ * @param {string[]} obj.effects - all effects to pass to SoX
179
+ *
180
+ * @example
181
+ * applyEffects({ program: "sox", stdoutHeader, readStream })
182
+ */
183
+ async function applyEffects({
184
+ program,
185
+ stdoutHeader, readStream,
186
+ promisesOfPrograms,
187
+ stdout = process.stdout,
188
+ destination = "-",
189
+ effects = ["reverb", (global?.reverbVolume) ? global.reverbVolume : "0", "36", "100", "100", "10", "10"]
190
+ }) {
191
+ /*
192
+ ffmpeg
193
+ -i -
194
+ -i parking-garage-response.wav
195
+ -lavfi "afir,volume=70"
196
+ -f wav
197
+ pipe:1
198
+ */
199
+ if (!spawn) ({ spawn } = await import("child_process"));
200
+ // In case it's custom
201
+ if (effects[0]?.effect) {
202
+ // cloning the effects array so that it can be unpacked
203
+ const oldEffectsArray = [...effects];
204
+ effects.length = 0;
205
+ oldEffectsArray
206
+ .forEach((i) => {
207
+ if (i.values) {
208
+ effects.push(i.effect, ...i.values)
209
+ return;
210
+ }
211
+ effects.push(i.effect)
212
+ })
213
+ }
214
+ const sox = spawn(program, [
215
+ "-t", "wav", "-",
216
+ "-t", "wav", destination,
217
+ ...effects
218
+ ], {stdio: ["pipe", stdout, "pipe"], detached: true})
219
+ // For SIGINT event to work, sometimes... ↑
220
+ log(1, performance.now().toFixed(2), "Spawned SoX with " + sox.spawnargs.join(" "))
221
+
222
+ promisesOfPrograms.push(
223
+ new Promise(resolve => {
224
+ sox.stderr.on("data", (data) => {
225
+ const stringOfError = data.toString();
226
+ // Do not print if these match stringOfError
227
+ if (stringOfError.match(/sox FAIL sox: \`-\' error writing output file: Connection reset by peer\n/g)
228
+ || stringOfError.match(/\n*sox WARN \w*:.*can't seek.*\n*/g)) return;
229
+
230
+ const modifiedString = stringOfError
231
+ .replace( // Adds yellow to numbers
232
+ /(-*[0-9]+(?:ms|dB|%|q)*)/g,
233
+ `${normalYellow}$1${normal}`
234
+ )
235
+ .replace( // Adds bold gray to the default parameters that can be overriden
236
+ /(\] |\) )(\[)([\w-]*)/g,
237
+ `$1$2${dimGrayBold}$3${normal}`
238
+ )
239
+ .replace( // Adds green to the parameter that has wrong values
240
+ /(parameter )(`\w*')/g,
241
+ `$1${green}$2${normal}`
242
+ )
243
+ .replace( // Adds red to the sox FAIL... text
244
+ /(sox FAIL \w*)/g,
245
+ `${red}$1${normal}`
246
+ )
247
+ .replace( // Adds yellow and a new line to the warn text for programs like mpv
248
+ /(sox WARN \w*)/g,
249
+ `\n${yellow}$1${normal}`
250
+ )
251
+ .replace( // Adds gray to the optional parameters for the effects
252
+ /(\[[ \w|-]*\])/g,
253
+ `${gray}$1${normal}`
254
+ )
255
+ // Patch for the regex above
256
+ .replace(/m\[0m/g, "\x1b[0m");
257
+
258
+ console.error(modifiedString);
259
+ })
260
+ sox.on("exit", () => resolve())
261
+ })
262
+ )
263
+ sox.stdin.write(stdoutHeader)
264
+ readStream.pipe(sox.stdin)
265
+ log(1, performance.now().toFixed(2), "Finished setting up SoX")
266
+ return promisesOfPrograms;
267
+ }
268
+ /**
269
+ * Adds events to process
270
+ * @param {Object} obj - the object passed
271
+ * @param {String} obj.eventType - the type of event to add
272
+ * @param {Function} obj.func - optional function for eventType "exit"
273
+ * @example
274
+ * addEvent({ eventType: "SIGINT" })
275
+ */
276
+ function addEvent({ eventType, func }) {
277
+ if (eventType === "uncaughtException") {
278
+ // Adds on top of spessasynth_core's uncaughtException
279
+ const oldUncaughtException = process.rawListeners("uncaughtException")[0];
280
+ process.removeListener("uncaughtException", oldUncaughtException)
281
+ process.on("uncaughtException", async (error) => {
282
+ if (global.SIGINT) return process.exit();
283
+ if (error?.code === "EPIPE") {
284
+ // Needed so that SoX can show its stderr
285
+ await new Promise(resolve => {
286
+ setTimeout(() => resolve(), 4);
287
+ })
288
+ console.error(`${gray}Closed the program before finishing to render${normal}`);
289
+ return process.exit(2);
290
+ }
291
+ oldUncaughtException(error)
292
+ })
293
+ return true;
294
+ }
295
+ if (eventType === "exit") {
296
+ process.on("exit", func)
297
+ return true;
298
+ }
299
+ if (eventType === "SIGINT") {
300
+ process.on("SIGINT", () => {
301
+ console.error(`${gray}Closed with Ctrl+c${normal}`);
302
+ global.SIGINT = true;
303
+ })
304
+ }
305
+ }
306
+ /**
307
+ * Creates a Readable stream given the variables needed
308
+ * @param {Readable} Readable - Readable stream function
309
+ * @param {Boolean} [isStdout=false] - if it's for toStdout or not
310
+ * @param {Object} obj - the object passed
311
+ * @param {Number} obj.BUFFER_SIZE - static size of the buffer
312
+ * @param {Number} obj.filledSamples - how many samples have been rendered
313
+ * @param {Boolean} obj.lastBytes - check if it's the last sample
314
+ * @param {Number} obj.sampleCount - sample count
315
+ * @param {Number} obj.sampleRate - sample rate
316
+ * @param {Number} obj.i - counter for the progress
317
+ * @param {Number} obj.durationRounded - duration of the song rounded by percentage
318
+ * @param {Function} obj.clearLastLines - util function to clear lines, see utils.mjs
319
+ * @param {class} obj.seq - spessasynth_core' sequencer
320
+ * @param {class} obj.synth - spessasynth_core's processor
321
+ * @param {Function} obj.getData - translator: Float32Arrays → Uint8Arrays
322
+ */
323
+ function createReadable(Readable, isStdout = false, {
324
+ BUFFER_SIZE, filledSamples,
325
+ lastBytes,
326
+ sampleCount, sampleRate,
327
+ i, durationRounded,
328
+ clearLastLines,
329
+ seq, synth,
330
+ getData
331
+ }) {
332
+ const readStream = new Readable({
333
+ read() {
334
+ const bufferSize = Math.min(BUFFER_SIZE, sampleCount - filledSamples);
335
+ const left = new Float32Array(bufferSize);
336
+ const right = new Float32Array(bufferSize);
337
+ const arr = [left, right]
338
+ seq.processTick();
339
+ synth.renderAudio(
340
+ arr, [], [],
341
+ 0,
342
+ bufferSize
343
+ );
344
+ filledSamples += bufferSize;
345
+ if (!isStdout) {
346
+ i++;
347
+ if (i % 100 === 0) {
348
+ if (i > 0) clearLastLines([0, -1])
349
+ console.info(
350
+ `Rendered ${magenta}` +
351
+ // Gets the ISO format and then gets mm:ss.sss
352
+ new Date(
353
+ (Math.floor(seq.currentTime * 100) / 100) * 1000
354
+ )
355
+ .toISOString()
356
+ .replace(/.*T...(.*)Z/, "$1") +
357
+ `${normal}`,
358
+ "/",
359
+ `${brightMagenta}` +
360
+ // Same down here
361
+ new Date(durationRounded * 1000)
362
+ .toISOString()
363
+ .replace(/.*T...(.*)Z/, "$1"),
364
+ `${normal}`
365
+ );
366
+ }
367
+ }
368
+
369
+ if (filledSamples <= sampleCount && !lastBytes) {
370
+ if (filledSamples === sampleCount) lastBytes = true;
371
+ const data = getData(arr, sampleRate);
372
+ return this.push(data)
373
+ }
374
+ this.push(null)
375
+ }
376
+ });
377
+ log(1, performance.now().toFixed(2), `Created Readable for ${(isStdout) ? "toStdout" : "toFile"}`)
378
+ return readStream;
379
+ }
380
+ /**
381
+ * Reads the generated samples from spessasynth_core
382
+ * and spits them out to stdout
383
+ * @param {Number} loopAmount - the number of loops to do
384
+ * @param {Number} volume - the volume of the song
385
+ * @param {Number} [obj=false] - object for additional options
386
+ * @param {ChildProcess} obj.mpv - mpv's process
387
+ * @param {Boolean} obj.isStartPlayer - if it's from startPlayer
388
+ * @param {class} obj.seq - spessasynth_core's sequencer
389
+ * @param {class} obj.synth - spessasynth_core's processor
390
+ * @param {Number} obj.sampleCount - sample count of the song
391
+ * @param {Number} obj.sampleRate - sample rate of the song
392
+ */
393
+ async function toStdout(
394
+ loopAmount,
395
+ volume = 100/100,
396
+ {
397
+ mpv,
398
+ isStartPlayer,
399
+ seq, synth,
400
+ sampleCount, sampleRate
401
+ } = {}
402
+ ) {
403
+ if (!global?.midiFile || !global?.soundfontFile) {
404
+ throw new ReferenceError("Missing some required files")
405
+ }
406
+ log(1, performance.now().toFixed(2), "Started toStdout")
407
+ if (!isStartPlayer) {
408
+ ({
409
+ seq, synth,
410
+ sampleCount, sampleRate
411
+ } = await initSpessaSynth(loopAmount, volume));
412
+ }
413
+
414
+ ({ spawn, spawnSync } = await import("child_process"));
415
+ if (!isStartPlayer) {
416
+ addEvent({ eventType: "exit",
417
+ func: () => {
418
+ // Necessary for programs like mpv
419
+ if (doneStreaming) {
420
+ let command,
421
+ commandToSend,
422
+ argumentsForCommand,
423
+ regexForCommand;
424
+ const arrayOfProgramsWinVersion = ["mpv.exe"];
425
+ const arrayOfPrograms = ["mpv"];
426
+
427
+ switch (process.platform) {
428
+ case "win32":
429
+ command = "tasklist";
430
+ argumentsForCommand = [];
431
+ regexForCommand = new RegExp(
432
+ `(?:${arrayOfProgramsWinVersion.join("|")})\\s*(?<pid>\\d+)`,
433
+ "g"
434
+ );
435
+ commandToSend = () => spawnSync("taskkill", [
436
+ "/PID", process.pid, "/T", "/F"
437
+ ]);
438
+ break;
439
+
440
+ case "linux":
441
+ case "android":
442
+ case "darwin":
443
+ command = "ps";
444
+ argumentsForCommand = [
445
+ "-o", "pid,comm",
446
+ "-C", "node,"+arrayOfPrograms.join(",")
447
+ ];
448
+ regexForCommand = new RegExp(
449
+ `(?<pid>\\d+) (?:${arrayOfPrograms.join("|")})`,
450
+ "g"
451
+ );
452
+ commandToSend = () => process.kill(process.pid, "SIGKILL");
453
+ break;
454
+ }
455
+
456
+ // Get PIDs by group name ?<pid>
457
+ const iteratorObject = spawnSync(command, argumentsForCommand)
458
+ .stdout.toString()
459
+ .matchAll(regexForCommand)
460
+ .map(i => i.groups);
461
+ // If it matches something,
462
+ // check whether it's a connected pipe to the program before SIGKILLing
463
+ for (const foundProgram of iteratorObject) {
464
+ if (Number(foundProgram.pid) >= process.pid
465
+ && Number(foundProgram.pid) <= process.pid+20) commandToSend()
466
+ if (process.platform === "win32") commandToSend()
467
+ }
468
+ }
469
+ }
470
+ })
471
+ }
472
+ log(1, performance.now().toFixed(2), "Added event exit")
473
+ const {
474
+ getWavHeader,
475
+ getData
476
+ } = await import("./audioBuffer.mjs")
477
+ const { Readable } = await import("node:stream");
478
+
479
+ const BUFFER_SIZE = 128;
480
+ let filledSamples = 0;
481
+ let lastBytes = false;
482
+ let doneStreaming = false;
483
+
484
+ const readStream = createReadable(Readable, true, {
485
+ BUFFER_SIZE, filledSamples,
486
+ lastBytes,
487
+ sampleCount, sampleRate,
488
+ seq, synth,
489
+ getData
490
+ });
491
+ const stdoutHeader = getWavHeader({ length: sampleCount, numChannels: 2 }, sampleRate);
492
+ log(1, performance.now().toFixed(2), "Created header file ", stdoutHeader)
493
+
494
+ const promisesOfPrograms = [];
495
+ switch (global?.format) {
496
+ case "wave": {
497
+ if (global?.effects) {
498
+ await applyEffects({
499
+ program: "sox",
500
+ stdoutHeader, readStream,
501
+ promisesOfPrograms,
502
+ stdout: (isStartPlayer) ? mpv.stdin : undefined,
503
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
504
+ })
505
+ log(1, performance.now().toFixed(2), "Done setting up")
506
+ break;
507
+ }
508
+ if (isStartPlayer) {
509
+ mpv.stdin.write(stdoutHeader)
510
+ readStream.pipe(mpv.stdin)
511
+ log(1, performance.now().toFixed(2), "Done setting up")
512
+ break;
513
+ }
514
+ process.stdout.write(stdoutHeader)
515
+ readStream.pipe(process.stdout)
516
+ log(1, performance.now().toFixed(2), "Done setting up")
517
+ break;
518
+ }
519
+ case "flac": {
520
+ const ffmpeg = spawn("ffmpeg",
521
+ ffmpegArgs().flac,
522
+ {stdio: [
523
+ "pipe",
524
+ (!isStartPlayer) ? process.stdout : mpv.stdin,
525
+ "pipe"
526
+ ], detached: true}
527
+ );
528
+ log(1, performance.now().toFixed(2), "Spawned ffmpeg with " + ffmpeg.spawnargs.join(" "))
529
+ if (global?.effects) {
530
+ await applyEffects({
531
+ program: "sox",
532
+ stdoutHeader, readStream,
533
+ promisesOfPrograms,
534
+ stdout: ffmpeg.stdin,
535
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
536
+ })
537
+ log(1, performance.now().toFixed(2), "Done setting up")
538
+ break;
539
+ }
540
+ promisesOfPrograms.push(
541
+ new Promise((resolve, reject) => {
542
+ ffmpeg.on("error", e => reject(e))
543
+ ffmpeg.on("exit", () => resolve())
544
+ })
545
+ )
546
+ log(1, performance.now().toFixed(2), "Added promise")
547
+ ffmpeg.stdin.write(stdoutHeader)
548
+ readStream.pipe(ffmpeg.stdin)
549
+ log(1, performance.now().toFixed(2), "Done setting up")
550
+ break;
551
+ }
552
+ case "mp3": {
553
+ const ffmpeg = spawn("ffmpeg",
554
+ ffmpegArgs().mp3,
555
+ {stdio: [
556
+ "pipe",
557
+ (!isStartPlayer) ? process.stdout : mpv.stdin,
558
+ "pipe"
559
+ ], detached: true}
560
+ );
561
+ log(1, performance.now().toFixed(2), "Spawned ffmpeg with " + ffmpeg.spawnargs.join(" "))
562
+ if (global?.effects) {
563
+ await applyEffects({
564
+ program: "sox",
565
+ stdoutHeader, readStream,
566
+ promisesOfPrograms,
567
+ stdout: ffmpeg.stdin,
568
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
569
+ })
570
+ log(1, performance.now().toFixed(2), "Done setting up")
571
+ break;
572
+ }
573
+ promisesOfPrograms.push(
574
+ new Promise((resolve, reject) => {
575
+ ffmpeg.on("error", e => reject(e))
576
+ ffmpeg.on("exit", () => resolve())
577
+ })
578
+ )
579
+ log(1, performance.now().toFixed(2), "Added promise")
580
+ ffmpeg.stdin.write(stdoutHeader)
581
+ readStream.pipe(ffmpeg.stdin)
582
+ log(1, performance.now().toFixed(2), "Done setting up")
583
+ break;
584
+ }
585
+ case "pcm": {
586
+ readStream.pipe((!isStartPlayer) ? process.stdout : mpv.stdin)
587
+ log(1, performance.now().toFixed(2), "Done setting up")
588
+ break;
589
+ }
590
+
591
+ default:
592
+ if (global?.effects) {
593
+ await applyEffects({
594
+ program: "sox",
595
+ stdoutHeader, readStream,
596
+ promisesOfPrograms,
597
+ stdout: (isStartPlayer) ? mpv.stdin : undefined,
598
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
599
+ })
600
+ log(1, performance.now().toFixed(2), "Done setting up")
601
+ break;
602
+ }
603
+ if (isStartPlayer) {
604
+ mpv.stdin.write(stdoutHeader)
605
+ readStream.pipe(mpv.stdin)
606
+ log(1, performance.now().toFixed(2), "Done setting up")
607
+ break;
608
+ }
609
+ process.stdout.write(stdoutHeader)
610
+ readStream.pipe(process.stdout)
611
+ log(1, performance.now().toFixed(2), "Done setting up")
612
+ }
613
+ await Promise.all([
614
+ new Promise((resolve, reject) => {
615
+ readStream.on("error", e => reject(e))
616
+ readStream.on("end", () => {
617
+ doneStreaming = true;
618
+ resolve()
619
+ })
620
+ }),
621
+ (isStartPlayer) ? new Promise((resolve, reject) => {
622
+ mpv.on("error", e => reject(e))
623
+ mpv.on("exit", () => resolve())
624
+ mpv.on("end", () => resolve())
625
+ }) : undefined,
626
+ ...promisesOfPrograms // If there are any
627
+ ])
628
+ log(1, performance.now().toFixed(2), (!isStartPlayer) ? "Finished printing to stdout" : "Finished sending data to mpv's process")
629
+ }
630
+
631
+ /**
632
+ * Reads the generated samples from spessasynth_core
633
+ * and renders them to a wav file
634
+ * @param {Number} loopAmount - the number of loops to do
635
+ * @param {Number} volume - the volume of the song
636
+ */
637
+ async function toFile(loopAmount, volume = 100/100) {
638
+ if (!global?.midiFile || !global?.soundfontFile || global.fileOutputs.length === 0 ) {
639
+ throw new ReferenceError("Missing some required files")
640
+ }
641
+ log(1, performance.now().toFixed(2), "Started toFile")
642
+ const {
643
+ seq, synth,
644
+ sampleCount, sampleRate
645
+ } = await initSpessaSynth(loopAmount, volume, true);
646
+
647
+ const {
648
+ getWavHeader,
649
+ getData
650
+ } = await import("./audioBuffer.mjs");
651
+ const { Readable } = await import("node:stream");
652
+ const { clearLastLines } = await import("./utils.mjs");
653
+
654
+ let i = 0;
655
+ const durationRounded = Math.floor(seq.midiData.duration * 100) / 100;
656
+
657
+ const BUFFER_SIZE = 128;
658
+ let filledSamples = 0;
659
+ let lastBytes = false;
660
+ const stdoutHeader = getWavHeader({ length: sampleCount, numChannels: 2 }, sampleRate);
661
+ log(1, performance.now().toFixed(2), "Created header file ", stdoutHeader)
662
+
663
+ const readStream = createReadable(Readable, false, {
664
+ BUFFER_SIZE, filledSamples,
665
+ lastBytes,
666
+ sampleCount, sampleRate,
667
+ seq, synth,
668
+ getData, i, durationRounded,
669
+ clearLastLines
670
+ });
671
+ const { newFileName } = await import("./utils.mjs");
672
+ const promisesOfPrograms = [];
673
+ for (let outFile of global.fileOutputs) {
674
+ switch (true) {
675
+ case /^.*(?:\.wav|\.wave)$/.test(outFile): {
676
+ const newName = newFileName(outFile);
677
+ global.fileOutputs[global.fileOutputs.indexOf(outFile)] = newName;
678
+ outFile = newName;
679
+
680
+ if (global?.effects) {
681
+ await applyEffects({
682
+ program: "sox",
683
+ stdoutHeader, readStream,
684
+ promisesOfPrograms,
685
+ destination: outFile,
686
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
687
+ })
688
+ log(1, performance.now().toFixed(2), "Done setting up wav outFile")
689
+ break;
690
+ }
691
+ const wav = fs.createWriteStream(outFile);
692
+ wav.write(stdoutHeader)
693
+ readStream.pipe(wav)
694
+ log(1, performance.now().toFixed(2), "Done setting up wav outFile")
695
+ break;
696
+ }
697
+ case /^.*\.flac$/.test(outFile): {
698
+ if (!spawn) ({ spawn } = await import("child_process"));
699
+ const newName = newFileName(outFile);
700
+ global.fileOutputs[global.fileOutputs.indexOf(outFile)] = newName;
701
+ outFile = newFileName(outFile);
702
+
703
+ const ffmpeg = spawn("ffmpeg", ffmpegArgs(outFile).flac);
704
+ log(1, performance.now().toFixed(2), "Spawned ffmpeg with " + ffmpeg.spawnargs.join(" "))
705
+ if (global?.effects) {
706
+ await applyEffects({
707
+ program: "sox",
708
+ stdoutHeader, readStream,
709
+ promisesOfPrograms,
710
+ stdout: ffmpeg.stdin,
711
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
712
+ })
713
+ log(1, performance.now().toFixed(2), "Done setting up flac outFile")
714
+ break;
715
+ }
716
+ promisesOfPrograms.push(
717
+ new Promise((resolve, reject) => {
718
+ ffmpeg.on("error", e => reject(e))
719
+ ffmpeg.on("exit", () => resolve())
720
+ })
721
+ )
722
+ log(1, performance.now().toFixed(2), "Added promise")
723
+ ffmpeg.stdin.write(stdoutHeader)
724
+ readStream.pipe(ffmpeg.stdin)
725
+ log(1, performance.now().toFixed(2), "Done setting up flac outFile")
726
+ break;
727
+ }
728
+ case /^.*\.mp3$/.test(outFile): {
729
+ if (!spawn) ({ spawn } = await import("child_process"));
730
+ const newName = newFileName(outFile);
731
+ global.fileOutputs[global.fileOutputs.indexOf(outFile)] = newName;
732
+ outFile = newFileName(outFile);
733
+
734
+ const ffmpeg = spawn("ffmpeg", ffmpegArgs(outFile).mp3);
735
+ log(1, performance.now().toFixed(2), "Spawned ffmpeg with " + ffmpeg.spawnargs.join(" "))
736
+ if (global?.effects) {
737
+ await applyEffects({
738
+ program: "sox",
739
+ stdoutHeader, readStream,
740
+ promisesOfPrograms,
741
+ stdout: ffmpeg.stdin,
742
+ effects: (Array.isArray(global?.effects)) ? global.effects : undefined
743
+ })
744
+ log(1, performance.now().toFixed(2), "Done setting up mp3 outFile")
745
+ break;
746
+ }
747
+ promisesOfPrograms.push(
748
+ new Promise((resolve, reject) => {
749
+ ffmpeg.on("error", e => reject(e))
750
+ ffmpeg.on("exit", () => resolve())
751
+ })
752
+ )
753
+ log(1, performance.now().toFixed(2), "Added promise")
754
+ ffmpeg.stdin.write(stdoutHeader)
755
+ readStream.pipe(ffmpeg.stdin)
756
+ log(1, performance.now().toFixed(2), "Done setting up mp3 outFile")
757
+ break;
758
+ }
759
+ case /^.*\.(?:s16le|s32le|pcm)$/.test(outFile): {
760
+ const newName = newFileName(outFile);
761
+ global.fileOutputs[global.fileOutputs.indexOf(outFile)] = newName;
762
+ outFile = newFileName(outFile);
763
+
764
+ const pcm = fs.createWriteStream(outFile);
765
+ readStream.pipe(pcm)
766
+ log(1, performance.now().toFixed(2), "Done setting up pcm outFile")
767
+ break;
768
+ }
769
+ }
770
+ }
771
+ await Promise.all([
772
+ new Promise((resolve, reject) => {
773
+ readStream.on("error", e => reject(e))
774
+ readStream.on("end", () => resolve())
775
+ }),
776
+ ...promisesOfPrograms // if there are any
777
+ ])
778
+ console.log("Written", global.fileOutputs.filter(ifil => ifil));
779
+ // Required because some child_processes sometimes blocks node from exiting
780
+ process.exit()
781
+ }
782
+
783
+ /**
784
+ * Reads the generated samples from spessasynth_core
785
+ * and plays them using mpv
786
+ * @param {Number} loopAmount - the number of loops to do
787
+ * @param {Number} volume - the volume of the song
788
+ */
789
+ async function startPlayer(loopAmount, volume = 100/100) {
790
+ ({ spawn, spawnSync } = await import("child_process"));
791
+ const {
792
+ seq, synth,
793
+ sampleCount, sampleRate
794
+ } = await initSpessaSynth(loopAmount, volume);
795
+ const isRawAudio = (global?.format === "pcm") ? [
796
+ "--demuxer=rawaudio",
797
+ "--demuxer-rawaudio-format=s16le",
798
+ "--demuxer-rawaudio-rate="+sampleRate,
799
+ "--demuxer-rawaudio-channels=2"
800
+ ] : "";
801
+ const mpv = spawn("mpv", [
802
+ ...isRawAudio,
803
+ "-"
804
+ ],
805
+ {stdio: ["pipe", "inherit", "inherit"]}
806
+ );
807
+ await toStdout(loopAmount, volume, {
808
+ mpv,
809
+ isStartPlayer: true,
810
+ seq, synth,
811
+ sampleCount, sampleRate
812
+ })
813
+ // Required because some child_processes sometimes blocks node from exiting
814
+ process.exit()
815
+ }