1.1
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- 394.mid +0 -0
- advanced_demo.js +109 -0
- examples.css +66 -0
- index - Copia.html +32 -0
- index.html +32 -0
- spessasynth_lib/external_midi/README.md +4 -0
- spessasynth_lib/external_midi/midi_handler.js +130 -0
- spessasynth_lib/external_midi/web_midi_link.js +43 -0
- spessasynth_lib/externals/fflate/LICENSE +21 -0
- spessasynth_lib/externals/fflate/fflate.min.js +1 -0
- spessasynth_lib/externals/stbvorbis_sync/@types/stbvorbis_sync.d.ts +12 -0
- spessasynth_lib/externals/stbvorbis_sync/LICENSE +202 -0
- spessasynth_lib/externals/stbvorbis_sync/NOTICE +6 -0
- spessasynth_lib/externals/stbvorbis_sync/stbvorbis_sync.min.js +0 -0
- spessasynth_lib/midi_parser/README.md +32 -0
- spessasynth_lib/midi_parser/basic_midi.js +563 -0
- spessasynth_lib/midi_parser/midi_builder.js +202 -0
- spessasynth_lib/midi_parser/midi_data.js +63 -0
- spessasynth_lib/midi_parser/midi_editor.js +611 -0
- spessasynth_lib/midi_parser/midi_loader.js +324 -0
- spessasynth_lib/midi_parser/midi_message.js +254 -0
- spessasynth_lib/midi_parser/midi_sequence.js +225 -0
- spessasynth_lib/midi_parser/midi_writer.js +99 -0
- spessasynth_lib/midi_parser/rmidi_writer.js +567 -0
- spessasynth_lib/midi_parser/used_keys_loaded.js +238 -0
- spessasynth_lib/midi_parser/xmf_loader.js +454 -0
- spessasynth_lib/sequencer/README.md +30 -0
- spessasynth_lib/sequencer/default_sequencer_options.js +8 -0
- spessasynth_lib/sequencer/sequencer.js +804 -0
- spessasynth_lib/sequencer/worklet_sequencer/events.js +199 -0
- spessasynth_lib/sequencer/worklet_sequencer/play.js +355 -0
- spessasynth_lib/sequencer/worklet_sequencer/process_event.js +169 -0
- spessasynth_lib/sequencer/worklet_sequencer/process_tick.js +106 -0
- spessasynth_lib/sequencer/worklet_sequencer/sequencer_message.js +53 -0
- spessasynth_lib/sequencer/worklet_sequencer/song_control.js +229 -0
- spessasynth_lib/sequencer/worklet_sequencer/worklet_sequencer.js +336 -0
- spessasynth_lib/soundfont/README.md +13 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_instrument.js +77 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_preset.js +336 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_sample.js +197 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_soundfont.js +565 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_zone.js +64 -0
- spessasynth_lib/soundfont/basic_soundfont/basic_zones.js +43 -0
- spessasynth_lib/soundfont/basic_soundfont/generator.js +220 -0
- spessasynth_lib/soundfont/basic_soundfont/modulator.js +378 -0
- spessasynth_lib/soundfont/basic_soundfont/riff_chunk.js +149 -0
- spessasynth_lib/soundfont/basic_soundfont/write_dls/art2.js +173 -0
- spessasynth_lib/soundfont/basic_soundfont/write_dls/articulator.js +49 -0
- spessasynth_lib/soundfont/basic_soundfont/write_dls/combine_zones.js +400 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
spessasynth_lib/soundfonts/GeneralUserGS.sf3 filter=lfs diff=lfs merge=lfs -text
|
394.mid
ADDED
|
Binary file (10.8 kB). View file
|
|
|
advanced_demo.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
// import the modules
|
| 14 |
+
import { WORKLET_URL_ABSOLUTE } from "./spessasynth_lib/synthetizer/worklet_url.js";
|
| 15 |
+
import { Sequencer } from "./spessasynth_lib/sequencer/sequencer.js";
|
| 16 |
+
import { Synthetizer } from "./spessasynth_lib/synthetizer/synthetizer.js";
|
| 17 |
+
|
| 18 |
+
// load the soundfont
|
| 19 |
+
fetch("./spessasynth_lib/soundfonts/GeneralUserGS.sf3").then(async response =>
|
| 20 |
+
{
|
| 21 |
+
// load the soundfont into an array buffer
|
| 22 |
+
let soundFontBuffer = await response.arrayBuffer();
|
| 23 |
+
document.getElementById("message").innerText = "SoundFont has been loaded!";
|
| 24 |
+
|
| 25 |
+
// create the context and add audio worklet
|
| 26 |
+
const context = new AudioContext();
|
| 27 |
+
await context.audioWorklet.addModule(new URL("./spessasynth_lib/" + WORKLET_URL_ABSOLUTE, import.meta.url));
|
| 28 |
+
const synth = new Synthetizer(context.destination, soundFontBuffer); // create the synthetizer
|
| 29 |
+
let seq;
|
| 30 |
+
|
| 31 |
+
// add an event listener for the file inout
|
| 32 |
+
document.getElementById("midi_input").addEventListener("change", async event =>
|
| 33 |
+
{
|
| 34 |
+
// check if any files are added
|
| 35 |
+
if (!event.target.files[0])
|
| 36 |
+
{
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
// resume the context if paused
|
| 40 |
+
await context.resume();
|
| 41 |
+
// parse all the files
|
| 42 |
+
const parsedSongs = [];
|
| 43 |
+
for (let file of event.target.files)
|
| 44 |
+
{
|
| 45 |
+
const buffer = await file.arrayBuffer();
|
| 46 |
+
parsedSongs.push({
|
| 47 |
+
binary: buffer, // binary: the binary data of the file
|
| 48 |
+
altName: file.name // altName: the fallback name if the MIDI doesn't have one. Here we set it to the file name
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
if (seq === undefined)
|
| 52 |
+
{
|
| 53 |
+
seq = new Sequencer(parsedSongs, synth); // create the sequencer with the parsed midis
|
| 54 |
+
seq.play(); // play the midi
|
| 55 |
+
}
|
| 56 |
+
else
|
| 57 |
+
{
|
| 58 |
+
seq.loadNewSongList(parsedSongs); // the sequencer is already created, no need to create a new one.
|
| 59 |
+
}
|
| 60 |
+
seq.loop = false; // the sequencer loops a single song by default
|
| 61 |
+
|
| 62 |
+
// make the slider move with the song
|
| 63 |
+
let slider = document.getElementById("progress");
|
| 64 |
+
setInterval(() =>
|
| 65 |
+
{
|
| 66 |
+
// slider ranges from 0 to 1000
|
| 67 |
+
slider.value = (seq.currentTime / seq.duration) * 1000;
|
| 68 |
+
}, 100);
|
| 69 |
+
|
| 70 |
+
// on song change, show the name
|
| 71 |
+
seq.addOnSongChangeEvent(e =>
|
| 72 |
+
{
|
| 73 |
+
document.getElementById("message").innerText = "Now playing: " + e.midiName;
|
| 74 |
+
}, "example-time-change"); // make sure to add a unique id!
|
| 75 |
+
|
| 76 |
+
// add time adjustment
|
| 77 |
+
slider.onchange = () =>
|
| 78 |
+
{
|
| 79 |
+
// calculate the time
|
| 80 |
+
seq.currentTime = (slider.value / 1000) * seq.duration; // switch the time (the sequencer adjusts automatically)
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
// add button controls
|
| 84 |
+
document.getElementById("previous").onclick = () =>
|
| 85 |
+
{
|
| 86 |
+
seq.previousSong(); // go back by one song
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// on pause click
|
| 90 |
+
document.getElementById("pause").onclick = () =>
|
| 91 |
+
{
|
| 92 |
+
if (seq.paused)
|
| 93 |
+
{
|
| 94 |
+
document.getElementById("pause").innerText = "Pause";
|
| 95 |
+
seq.play(); // resume
|
| 96 |
+
}
|
| 97 |
+
else
|
| 98 |
+
{
|
| 99 |
+
document.getElementById("pause").innerText = "Resume";
|
| 100 |
+
seq.pause(); // pause
|
| 101 |
+
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
document.getElementById("next").onclick = () =>
|
| 105 |
+
{
|
| 106 |
+
seq.nextSong(); // go to the next song
|
| 107 |
+
};
|
| 108 |
+
});
|
| 109 |
+
});
|
examples.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.txtlogee{
|
| 2 |
+
text-align: center;
|
| 3 |
+
text-transform: uppercase !important;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.playcax{
|
| 7 |
+
text-align: center;
|
| 8 |
+
margin: 15px 0px;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
* {
|
| 12 |
+
font-family: "Noto Sans Light", "Open Sans Light", sans-serif;
|
| 13 |
+
color: #ccc;
|
| 14 |
+
font-size: 1.1rem;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
button {
|
| 18 |
+
background: #444;
|
| 19 |
+
border: solid #555;
|
| 20 |
+
padding: 10px;
|
| 21 |
+
border-radius: 1rem;
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
margin: 0.3rem;
|
| 24 |
+
display: inline-block;
|
| 25 |
+
text-transform: uppercase !important;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
button:hover {
|
| 29 |
+
filter: brightness(1.1);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
button:active {
|
| 33 |
+
transform: scale(0.9);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
canvas {
|
| 37 |
+
width: 100%;
|
| 38 |
+
min-height: 0;
|
| 39 |
+
max-height: 40vh;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
input[type="range"] {
|
| 43 |
+
width: 100%;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
body {
|
| 47 |
+
background: #111;
|
| 48 |
+
display: flex;
|
| 49 |
+
flex-direction: column;
|
| 50 |
+
align-items: center;
|
| 51 |
+
max-height: 100vh;
|
| 52 |
+
margin: 50px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.example_content {
|
| 56 |
+
width: 80%;
|
| 57 |
+
background: #333;
|
| 58 |
+
padding: 2em;
|
| 59 |
+
margin: 1rem;
|
| 60 |
+
/*border-radius: 1rem;*/
|
| 61 |
+
box-shadow: black 0 0 15px;
|
| 62 |
+
/*display: flex;*/
|
| 63 |
+
flex-direction: column;
|
| 64 |
+
align-items: center;
|
| 65 |
+
}
|
| 66 |
+
|
index - Copia.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang='en'>
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset='UTF-8'>
|
| 6 |
+
<meta content='width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0'
|
| 7 |
+
name='viewport'>
|
| 8 |
+
<meta content='ie=edge' http-equiv='X-UA-Compatible'>
|
| 9 |
+
<title>SpessaSynth advanced example</title>
|
| 10 |
+
<link href='./examples.css' rel='stylesheet'>
|
| 11 |
+
</head>
|
| 12 |
+
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
<div class='example_content'>
|
| 17 |
+
<p id='message'>Please wait for the soundFont to load.</p>
|
| 18 |
+
<input accept='.mid, .rmi, .xmf, .mxmf' id='midi_input' multiple type='file'>
|
| 19 |
+
<br><br>
|
| 20 |
+
<input id='progress' max='1000' min='0' type='range' value='0'>
|
| 21 |
+
<br>
|
| 22 |
+
|
| 23 |
+
<button id='previous'>Previous song</button>
|
| 24 |
+
<button id='pause'>Pause</button>
|
| 25 |
+
<button id='next'>Next song</button>
|
| 26 |
+
|
| 27 |
+
<!-- note the type="module" -->
|
| 28 |
+
<script src='./advanced_demo.js' type='module'></script>
|
| 29 |
+
</div>
|
| 30 |
+
</body>
|
| 31 |
+
|
| 32 |
+
</html>
|
index.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang='en'>
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset='UTF-8'>
|
| 6 |
+
<meta content='width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0'
|
| 7 |
+
name='viewport'>
|
| 8 |
+
<meta content='ie=edge' http-equiv='X-UA-Compatible'>
|
| 9 |
+
<title>SpessaSynth advanced example</title>
|
| 10 |
+
<link href='./examples.css' rel='stylesheet'>
|
| 11 |
+
</head>
|
| 12 |
+
|
| 13 |
+
<body>
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
<div class='example_content'>
|
| 17 |
+
<p id='message'>Please wait for the soundFont to load.</p>
|
| 18 |
+
<input accept='.mid, .rmi, .xmf, .mxmf' id='midi_input' multiple type='file'>
|
| 19 |
+
<br><br>
|
| 20 |
+
<input id='progress' max='1000' min='0' type='range' value='0'>
|
| 21 |
+
<br>
|
| 22 |
+
|
| 23 |
+
<button id='previous'>Previous song</button>
|
| 24 |
+
<button id='pause'>Pause</button>
|
| 25 |
+
<button id='next'>Next song</button>
|
| 26 |
+
|
| 27 |
+
<!-- note the type="module" -->
|
| 28 |
+
<script src='./advanced_demo.js' type='module'></script>
|
| 29 |
+
</div>
|
| 30 |
+
</body>
|
| 31 |
+
|
| 32 |
+
</html>
|
spessasynth_lib/external_midi/README.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## This is the MIDI handling folder.
|
| 2 |
+
|
| 3 |
+
The code here is respnsible for dealing with MIDI Inputs and outputs
|
| 4 |
+
and also for the WebMidiLink functionality.
|
spessasynth_lib/external_midi/midi_handler.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Synthetizer } from "../synthetizer/synthetizer.js";
|
| 2 |
+
import { consoleColors } from "../utils/other.js";
|
| 3 |
+
import { SpessaSynthInfo, SpessaSynthWarn } from "../utils/loggin.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* midi_handler.js
|
| 7 |
+
* purpose: handles the connection between MIDI devices and synthesizer/sequencer via Web MIDI API
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
const NO_INPUT = null;
|
| 11 |
+
|
| 12 |
+
export class MIDIDeviceHandler
|
| 13 |
+
{
|
| 14 |
+
constructor()
|
| 15 |
+
{
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* @returns {Promise<boolean>} if succeded
|
| 20 |
+
*/
|
| 21 |
+
async createMIDIDeviceHandler()
|
| 22 |
+
{
|
| 23 |
+
/**
|
| 24 |
+
* @type {MIDIInput}
|
| 25 |
+
*/
|
| 26 |
+
this.selectedInput = NO_INPUT;
|
| 27 |
+
/**
|
| 28 |
+
* @type {MIDIOutput}
|
| 29 |
+
*/
|
| 30 |
+
this.selectedOutput = NO_INPUT;
|
| 31 |
+
if (navigator.requestMIDIAccess)
|
| 32 |
+
{
|
| 33 |
+
// prepare the midi access
|
| 34 |
+
try
|
| 35 |
+
{
|
| 36 |
+
const response = await navigator.requestMIDIAccess({ sysex: true, software: true });
|
| 37 |
+
this.inputs = response.inputs;
|
| 38 |
+
this.outputs = response.outputs;
|
| 39 |
+
SpessaSynthInfo("%cMIDI handler created!", consoleColors.recognized);
|
| 40 |
+
return true;
|
| 41 |
+
}
|
| 42 |
+
catch (e)
|
| 43 |
+
{
|
| 44 |
+
SpessaSynthWarn(`Could not get MIDI Devices:`, e);
|
| 45 |
+
this.inputs = [];
|
| 46 |
+
this.outputs = [];
|
| 47 |
+
return false;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
else
|
| 51 |
+
{
|
| 52 |
+
SpessaSynthWarn("Web MIDI Api not supported!", consoleColors.unrecognized);
|
| 53 |
+
this.inputs = [];
|
| 54 |
+
this.outputs = [];
|
| 55 |
+
return false;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Connects the sequencer to a given MIDI output port
|
| 61 |
+
* @param output {MIDIOutput}
|
| 62 |
+
* @param seq {Sequencer}
|
| 63 |
+
*/
|
| 64 |
+
connectMIDIOutputToSeq(output, seq)
|
| 65 |
+
{
|
| 66 |
+
this.selectedOutput = output;
|
| 67 |
+
seq.connectMidiOutput(output);
|
| 68 |
+
SpessaSynthInfo(
|
| 69 |
+
`%cPlaying MIDI to %c${output.name}`,
|
| 70 |
+
consoleColors.info,
|
| 71 |
+
consoleColors.recognized
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Disconnects a midi output port from the sequencer
|
| 77 |
+
* @param seq {Sequencer}
|
| 78 |
+
*/
|
| 79 |
+
disconnectSeqFromMIDI(seq)
|
| 80 |
+
{
|
| 81 |
+
this.selectedOutput = NO_INPUT;
|
| 82 |
+
seq.connectMidiOutput(undefined);
|
| 83 |
+
SpessaSynthInfo(
|
| 84 |
+
"%cDisconnected from MIDI out.",
|
| 85 |
+
consoleColors.info
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Connects a MIDI input to the synthesizer
|
| 91 |
+
* @param input {MIDIInput}
|
| 92 |
+
* @param synth {Synthetizer}
|
| 93 |
+
*/
|
| 94 |
+
connectDeviceToSynth(input, synth)
|
| 95 |
+
{
|
| 96 |
+
this.selectedInput = input;
|
| 97 |
+
input.onmidimessage = event =>
|
| 98 |
+
{
|
| 99 |
+
synth.sendMessage(event.data);
|
| 100 |
+
};
|
| 101 |
+
SpessaSynthInfo(
|
| 102 |
+
`%cListening for messages on %c${input.name}`,
|
| 103 |
+
consoleColors.info,
|
| 104 |
+
consoleColors.recognized
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* @param input {MIDIInput}
|
| 110 |
+
*/
|
| 111 |
+
disconnectDeviceFromSynth(input)
|
| 112 |
+
{
|
| 113 |
+
this.selectedInput = NO_INPUT;
|
| 114 |
+
input.onmidimessage = undefined;
|
| 115 |
+
SpessaSynthInfo(
|
| 116 |
+
`%cDisconnected from %c${input.name}`,
|
| 117 |
+
consoleColors.info,
|
| 118 |
+
consoleColors.recognized
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
disconnectAllDevicesFromSynth()
|
| 123 |
+
{
|
| 124 |
+
this.selectedInput = NO_INPUT;
|
| 125 |
+
for (const i of this.inputs)
|
| 126 |
+
{
|
| 127 |
+
i[1].onmidimessage = undefined;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
spessasynth_lib/external_midi/web_midi_link.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Synthetizer } from "../synthetizer/synthetizer.js";
|
| 2 |
+
import { consoleColors } from "../utils/other.js";
|
| 3 |
+
import { SpessaSynthInfo } from "../utils/loggin.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* web_midi_link.js
|
| 7 |
+
* purpose: handles the web midi link connection to the synthesizer
|
| 8 |
+
* https://www.g200kg.com/en/docs/webmidilink/
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
export class WebMIDILinkHandler
|
| 12 |
+
{
|
| 13 |
+
/**
|
| 14 |
+
* @param synth {Synthetizer} the synth to play to
|
| 15 |
+
*/
|
| 16 |
+
constructor(synth)
|
| 17 |
+
{
|
| 18 |
+
|
| 19 |
+
window.addEventListener("message", msg =>
|
| 20 |
+
{
|
| 21 |
+
if (typeof msg.data !== "string")
|
| 22 |
+
{
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
/**
|
| 26 |
+
* @type {string[]}
|
| 27 |
+
*/
|
| 28 |
+
const data = msg.data.split(",");
|
| 29 |
+
if (data[0] !== "midi")
|
| 30 |
+
{
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
data.shift(); // remove MIDI
|
| 35 |
+
|
| 36 |
+
const midiData = data.map(byte => parseInt(byte, 16));
|
| 37 |
+
|
| 38 |
+
synth.sendMessage(midiData);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
SpessaSynthInfo("%cWeb MIDI Link handler created!", consoleColors.recognized);
|
| 42 |
+
}
|
| 43 |
+
}
|
spessasynth_lib/externals/fflate/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2023 Arjun Barrett
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
spessasynth_lib/externals/fflate/fflate.min.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
let tr;(()=>{var l=Uint8Array,T=Uint16Array,ur=Int32Array,W=new l([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),X=new l([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),wr=new l([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Y=function(r,a){for(var e=new T(31),f=0;f<31;++f)e[f]=a+=1<<r[f-1];for(var v=new ur(e[30]),f=1;f<30;++f)for(var g=e[f];g<e[f+1];++g)v[g]=g-e[f]<<5|f;return{b:e,r:v}},Z=Y(W,2),$=Z.b,cr=Z.r;$[28]=258,cr[258]=28;var j=Y(X,0),hr=j.b,Fr=j.r,_=new T(32768);for(i=0;i<32768;++i)c=(i&43690)>>1|(i&21845)<<1,c=(c&52428)>>2|(c&13107)<<2,c=(c&61680)>>4|(c&3855)<<4,_[i]=((c&65280)>>8|(c&255)<<8)>>1;var c,i,A=function(r,a,e){for(var f=r.length,v=0,g=new T(a);v<f;++v)r[v]&&++g[r[v]-1];var k=new T(a);for(v=1;v<a;++v)k[v]=k[v-1]+g[v-1]<<1;var b;if(e){b=new T(1<<a);var m=15-a;for(v=0;v<f;++v)if(r[v])for(var U=v<<4|r[v],x=a-r[v],n=k[r[v]-1]++<<x,o=n|(1<<x)-1;n<=o;++n)b[_[n]>>m]=U}else for(b=new T(f),v=0;v<f;++v)r[v]&&(b[v]=_[k[r[v]-1]++]>>15-r[v]);return b},M=new l(288);for(i=0;i<144;++i)M[i]=8;var i;for(i=144;i<256;++i)M[i]=9;var i;for(i=256;i<280;++i)M[i]=7;var i;for(i=280;i<288;++i)M[i]=8;var i,L=new l(32);for(i=0;i<32;++i)L[i]=5;var i,gr=A(M,9,1),br=A(L,5,1),q=function(r){for(var a=r[0],e=1;e<r.length;++e)r[e]>a&&(a=r[e]);return a},u=function(r,a,e){var f=a/8|0;return(r[f]|r[f+1]<<8)>>(a&7)&e},C=function(r,a){var e=a/8|0;return(r[e]|r[e+1]<<8|r[e+2]<<16)>>(a&7)},kr=function(r){return(r+7)/8|0},xr=function(r,a,e){return(a==null||a<0)&&(a=0),(e==null||e>r.length)&&(e=r.length),new l(r.subarray(a,e))},yr=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],h=function(r,a,e){var f=new Error(a||yr[r]);if(f.code=r,Error.captureStackTrace&&Error.captureStackTrace(f,h),!e)throw f;return f},Sr=function(r,a,e,f){var v=r.length,g=f?f.length:0;if(!v||a.f&&!a.l)return e||new l(0);var k=!e,b=k||a.i!=2,m=a.i;k&&(e=new l(v*3));var U=function(fr){var or=e.length;if(fr>or){var lr=new l(Math.max(or*2,fr));lr.set(e),e=lr}},x=a.f||0,n=a.p||0,o=a.b||0,S=a.l,I=a.d,z=a.m,D=a.n,G=v*8;do{if(!S){x=u(r,n,1);var H=u(r,n+1,3);if(n+=3,H)if(H==1)S=gr,I=br,z=9,D=5;else if(H==2){var N=u(r,n,31)+257,s=u(r,n+10,15)+4,d=N+u(r,n+5,31)+1;n+=14;for(var F=new l(d),P=new l(19),t=0;t<s;++t)P[wr[t]]=u(r,n+t*3,7);n+=s*3;for(var rr=q(P),Ar=(1<<rr)-1,Mr=A(P,rr,1),t=0;t<d;){var ar=Mr[u(r,n,Ar)];n+=ar&15;var w=ar>>4;if(w<16)F[t++]=w;else{var E=0,O=0;for(w==16?(O=3+u(r,n,3),n+=2,E=F[t-1]):w==17?(O=3+u(r,n,7),n+=3):w==18&&(O=11+u(r,n,127),n+=7);O--;)F[t++]=E}}var er=F.subarray(0,N),y=F.subarray(N);z=q(er),D=q(y),S=A(er,z,1),I=A(y,D,1)}else h(1);else{var w=kr(n)+4,J=r[w-4]|r[w-3]<<8,K=w+J;if(K>v){m&&h(0);break}b&&U(o+J),e.set(r.subarray(w,K),o),a.b=o+=J,a.p=n=K*8,a.f=x;continue}if(n>G){m&&h(0);break}}b&&U(o+131072);for(var Ur=(1<<z)-1,zr=(1<<D)-1,Q=n;;Q=n){var E=S[C(r,n)&Ur],p=E>>4;if(n+=E&15,n>G){m&&h(0);break}if(E||h(2),p<256)e[o++]=p;else if(p==256){Q=n,S=null;break}else{var nr=p-254;if(p>264){var t=p-257,B=W[t];nr=u(r,n,(1<<B)-1)+$[t],n+=B}var R=I[C(r,n)&zr],V=R>>4;R||h(3),n+=R&15;var y=hr[V];if(V>3){var B=X[V];y+=C(r,n)&(1<<B)-1,n+=B}if(n>G){m&&h(0);break}b&&U(o+131072);var vr=o+nr;if(o<y){var ir=g-y,Dr=Math.min(y,vr);for(ir+o<0&&h(3);o<Dr;++o)e[o]=f[ir+o]}for(;o<vr;++o)e[o]=e[o-y]}}a.l=S,a.p=Q,a.b=o,a.f=x,S&&(x=1,a.m=z,a.d=I,a.n=D)}while(!x);return o!=e.length&&k?xr(e,0,o):e.subarray(0,o)},Tr=new l(0);function mr(r,a){return Sr(r,{i:2},a&&a.out,a&&a.dictionary)}var Er=typeof TextDecoder<"u"&&new TextDecoder,pr=0;try{Er.decode(Tr,{stream:!0}),pr=1}catch{}tr=mr})();export{tr as inflateSync};
|
spessasynth_lib/externals/stbvorbis_sync/@types/stbvorbis_sync.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare type DecodedData =
|
| 2 |
+
{
|
| 3 |
+
data: Float32Array[],
|
| 4 |
+
error: string | null,
|
| 5 |
+
sampleRate: number,
|
| 6 |
+
eof: boolean
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
declare const stbvorbis: {
|
| 10 |
+
decode(buffer: ArrayBuffer): DecodedData
|
| 11 |
+
isInitialized: Promise<boolean>
|
| 12 |
+
}
|
spessasynth_lib/externals/stbvorbis_sync/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "{}"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright {yyyy} {name of copyright owner}
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
| 202 |
+
|
spessasynth_lib/externals/stbvorbis_sync/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
LICENSE is for stbvorbis_sync.js which is licensed under Apache-2.0
|
| 2 |
+
|
| 3 |
+
Modifications made to stbvorbis_sync.js
|
| 4 |
+
1. minified the code
|
| 5 |
+
2. added types declaration
|
| 6 |
+
3. changed the not initialized error message
|
spessasynth_lib/externals/stbvorbis_sync/stbvorbis_sync.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
spessasynth_lib/midi_parser/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## This is the MIDI file parsing folder.
|
| 2 |
+
|
| 3 |
+
The code here is responsible for parsing the MIDI files and interpreting the messsages.
|
| 4 |
+
All the events are defined in the `midi_message.js` file.
|
| 5 |
+
|
| 6 |
+
### MIDI Classes hierarchy
|
| 7 |
+
|
| 8 |
+
#### MIDI Sequence
|
| 9 |
+
- The most basic class, containing all the metadata that inheritors have.
|
| 10 |
+
- It does not contain track data or embedded sound bank.
|
| 11 |
+
- Contains the function for calculating time from ticks.
|
| 12 |
+
- Contains the copying code.
|
| 13 |
+
|
| 14 |
+
#### MIDI Data
|
| 15 |
+
- Inherits from MIDI Sequence.
|
| 16 |
+
- Has an `isEmbedded` property to mark the existence of embedded bank. This is the class that is available in `sequencer.midiData`.
|
| 17 |
+
|
| 18 |
+
#### Basic MIDI
|
| 19 |
+
- Inherits from MIDI Sequence.
|
| 20 |
+
- The actual MIDI representation, containing the track data and embedded sound banks.
|
| 21 |
+
- Contains the code for parsing the MIDI and filling in the metadata automatically.
|
| 22 |
+
- Contains the SMF/RMI writing functions.
|
| 23 |
+
- Contains the code for determining used channels on tracks.
|
| 24 |
+
|
| 25 |
+
#### MIDI Builder
|
| 26 |
+
- Inherits from Basic MIDI.
|
| 27 |
+
- Used for building MIDIs from scratch.
|
| 28 |
+
|
| 29 |
+
### MIDI
|
| 30 |
+
- Inherits from Basic MIDI.
|
| 31 |
+
- The SMF/RMI/XMF file parser.
|
| 32 |
+
- Called by the sequencer if an `ArrayBuffer` is provided.
|
spessasynth_lib/midi_parser/basic_midi.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MIDISequenceData } from "./midi_sequence.js";
|
| 2 |
+
import { getStringBytes, readBytesAsString } from "../utils/byte_functions/string.js";
|
| 3 |
+
import { messageTypes, MIDIMessage } from "./midi_message.js";
|
| 4 |
+
import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
|
| 5 |
+
import { SpessaSynthGroup, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
|
| 6 |
+
import { consoleColors, formatTitle, sanitizeKarLyrics } from "../utils/other.js";
|
| 7 |
+
import { writeMIDI } from "./midi_writer.js";
|
| 8 |
+
import { applySnapshotToMIDI, modifyMIDI } from "./midi_editor.js";
|
| 9 |
+
import { writeRMIDI } from "./rmidi_writer.js";
|
| 10 |
+
import { getUsedProgramsAndKeys } from "./used_keys_loaded.js";
|
| 11 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* BasicMIDI is the base of a complete MIDI file, used by the sequencer internally.
|
| 15 |
+
* BasicMIDI is not available on the main thread, as it contains the actual track data which can be large.
|
| 16 |
+
* It can be accessed by calling getMIDI() on the Sequencer.
|
| 17 |
+
*/
|
| 18 |
+
class BasicMIDI extends MIDISequenceData
|
| 19 |
+
{
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* The embedded soundfont in the MIDI file, represented as an ArrayBuffer, if available.
|
| 23 |
+
* @type {ArrayBuffer|undefined}
|
| 24 |
+
*/
|
| 25 |
+
embeddedSoundFont = undefined;
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* The actual track data of the MIDI file, represented as an array of tracks.
|
| 29 |
+
* Tracks are arrays of MIDIMessage objects.
|
| 30 |
+
* @type {MIDIMessage[][]}
|
| 31 |
+
*/
|
| 32 |
+
tracks = [];
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* If the MIDI file is a DLS RMIDI file.
|
| 36 |
+
* @type {boolean}
|
| 37 |
+
*/
|
| 38 |
+
isDLSRMIDI = false;
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Copies a MIDI
|
| 42 |
+
* @param mid {BasicMIDI}
|
| 43 |
+
* @returns {BasicMIDI}
|
| 44 |
+
*/
|
| 45 |
+
static copyFrom(mid)
|
| 46 |
+
{
|
| 47 |
+
const m = new BasicMIDI();
|
| 48 |
+
m._copyFromSequence(mid);
|
| 49 |
+
|
| 50 |
+
m.isDLSRMIDI = mid.isDLSRMIDI;
|
| 51 |
+
m.embeddedSoundFont = mid.embeddedSoundFont ? mid.embeddedSoundFont.slice(0) : undefined; // Deep copy
|
| 52 |
+
m.tracks = mid.tracks.map(track => [...track]); // Shallow copy of each track array
|
| 53 |
+
|
| 54 |
+
return m;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Parses internal MIDI values
|
| 59 |
+
* @protected
|
| 60 |
+
*/
|
| 61 |
+
_parseInternal()
|
| 62 |
+
{
|
| 63 |
+
SpessaSynthGroup(
|
| 64 |
+
"%cInterpreting MIDI events...",
|
| 65 |
+
consoleColors.info
|
| 66 |
+
);
|
| 67 |
+
/**
|
| 68 |
+
* For karaoke files, text events starting with @T are considered titles,
|
| 69 |
+
* usually the first one is the title, and the latter is things such as "sequenced by" etc.
|
| 70 |
+
* @type {boolean}
|
| 71 |
+
*/
|
| 72 |
+
let karaokeHasTitle = false;
|
| 73 |
+
|
| 74 |
+
this.keyRange = { max: 0, min: 127 };
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Will be joined with "\n" to form the final string
|
| 78 |
+
* @type {string[]}
|
| 79 |
+
*/
|
| 80 |
+
let copyrightComponents = [];
|
| 81 |
+
let copyrightDetected = false;
|
| 82 |
+
if (typeof this.RMIDInfo["ICOP"] !== "undefined")
|
| 83 |
+
{
|
| 84 |
+
// if RMIDI has copyright info, don't try to detect one.
|
| 85 |
+
copyrightDetected = true;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
let nameDetected = false;
|
| 90 |
+
if (typeof this.RMIDInfo["INAM"] !== "undefined")
|
| 91 |
+
{
|
| 92 |
+
// same as with copyright
|
| 93 |
+
nameDetected = true;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// loop tracking
|
| 97 |
+
let loopStart = null;
|
| 98 |
+
let loopEnd = null;
|
| 99 |
+
|
| 100 |
+
for (let i = 0; i < this.tracks.length; i++)
|
| 101 |
+
{
|
| 102 |
+
/**
|
| 103 |
+
* @type {MIDIMessage[]}
|
| 104 |
+
*/
|
| 105 |
+
const track = this.tracks[i];
|
| 106 |
+
const usedChannels = new Set();
|
| 107 |
+
let trackHasVoiceMessages = false;
|
| 108 |
+
|
| 109 |
+
for (const e of track)
|
| 110 |
+
{
|
| 111 |
+
// check if it's a voice message
|
| 112 |
+
if (e.messageStatusByte >= 0x80 && e.messageStatusByte < 0xF0)
|
| 113 |
+
{
|
| 114 |
+
trackHasVoiceMessages = true;
|
| 115 |
+
// voice messages are 7-bit always
|
| 116 |
+
for (let j = 0; j < e.messageData.length; j++)
|
| 117 |
+
{
|
| 118 |
+
e.messageData[j] = Math.min(127, e.messageData[j]);
|
| 119 |
+
}
|
| 120 |
+
// last voice event tick
|
| 121 |
+
if (e.ticks > this.lastVoiceEventTick)
|
| 122 |
+
{
|
| 123 |
+
this.lastVoiceEventTick = e.ticks;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// interpret the voice message
|
| 127 |
+
switch (e.messageStatusByte & 0xF0)
|
| 128 |
+
{
|
| 129 |
+
// cc change: loop points
|
| 130 |
+
case messageTypes.controllerChange:
|
| 131 |
+
switch (e.messageData[0])
|
| 132 |
+
{
|
| 133 |
+
case 2:
|
| 134 |
+
case 116:
|
| 135 |
+
loopStart = e.ticks;
|
| 136 |
+
break;
|
| 137 |
+
|
| 138 |
+
case 4:
|
| 139 |
+
case 117:
|
| 140 |
+
if (loopEnd === null)
|
| 141 |
+
{
|
| 142 |
+
loopEnd = e.ticks;
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
// this controller has occurred more than once;
|
| 147 |
+
// this means
|
| 148 |
+
// that it doesn't indicate the loop
|
| 149 |
+
loopEnd = 0;
|
| 150 |
+
}
|
| 151 |
+
break;
|
| 152 |
+
|
| 153 |
+
case 0:
|
| 154 |
+
// check RMID
|
| 155 |
+
if (this.isDLSRMIDI && e.messageData[1] !== 0 && e.messageData[1] !== 127)
|
| 156 |
+
{
|
| 157 |
+
SpessaSynthInfo(
|
| 158 |
+
"%cDLS RMIDI with offset 1 detected!",
|
| 159 |
+
consoleColors.recognized
|
| 160 |
+
);
|
| 161 |
+
this.bankOffset = 1;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
break;
|
| 165 |
+
|
| 166 |
+
// note on: used notes tracking and key range
|
| 167 |
+
case messageTypes.noteOn:
|
| 168 |
+
usedChannels.add(e.messageStatusByte & 0x0F);
|
| 169 |
+
const note = e.messageData[0];
|
| 170 |
+
this.keyRange.min = Math.min(this.keyRange.min, note);
|
| 171 |
+
this.keyRange.max = Math.max(this.keyRange.max, note);
|
| 172 |
+
break;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
e.messageData.currentIndex = 0;
|
| 176 |
+
const eventText = readBytesAsString(e.messageData, e.messageData.length);
|
| 177 |
+
e.messageData.currentIndex = 0;
|
| 178 |
+
// interpret the message
|
| 179 |
+
switch (e.messageStatusByte)
|
| 180 |
+
{
|
| 181 |
+
case messageTypes.setTempo:
|
| 182 |
+
// add the tempo change
|
| 183 |
+
e.messageData.currentIndex = 0;
|
| 184 |
+
this.tempoChanges.push({
|
| 185 |
+
ticks: e.ticks,
|
| 186 |
+
tempo: 60000000 / readBytesAsUintBigEndian(e.messageData, 3)
|
| 187 |
+
});
|
| 188 |
+
e.messageData.currentIndex = 0;
|
| 189 |
+
break;
|
| 190 |
+
|
| 191 |
+
case messageTypes.marker:
|
| 192 |
+
// check for loop markers
|
| 193 |
+
const text = eventText.trim().toLowerCase();
|
| 194 |
+
switch (text)
|
| 195 |
+
{
|
| 196 |
+
default:
|
| 197 |
+
break;
|
| 198 |
+
|
| 199 |
+
case "start":
|
| 200 |
+
case "loopstart":
|
| 201 |
+
loopStart = e.ticks;
|
| 202 |
+
break;
|
| 203 |
+
|
| 204 |
+
case "loopend":
|
| 205 |
+
loopEnd = e.ticks;
|
| 206 |
+
}
|
| 207 |
+
e.messageData.currentIndex = 0;
|
| 208 |
+
break;
|
| 209 |
+
|
| 210 |
+
case messageTypes.copyright:
|
| 211 |
+
if (!copyrightDetected)
|
| 212 |
+
{
|
| 213 |
+
e.messageData.currentIndex = 0;
|
| 214 |
+
copyrightComponents.push(readBytesAsString(
|
| 215 |
+
e.messageData,
|
| 216 |
+
e.messageData.length,
|
| 217 |
+
undefined,
|
| 218 |
+
false
|
| 219 |
+
));
|
| 220 |
+
e.messageData.currentIndex = 0;
|
| 221 |
+
}
|
| 222 |
+
break;
|
| 223 |
+
|
| 224 |
+
case messageTypes.lyric:
|
| 225 |
+
// note here: .kar files sometimes just use...
|
| 226 |
+
// lyrics instead of text because why not (of course)
|
| 227 |
+
// perform the same check for @KMIDI KARAOKE FILE
|
| 228 |
+
if (eventText.trim().startsWith("@KMIDI KARAOKE FILE"))
|
| 229 |
+
{
|
| 230 |
+
this.isKaraokeFile = true;
|
| 231 |
+
SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if (this.isKaraokeFile)
|
| 235 |
+
{
|
| 236 |
+
// replace the type of the message with text
|
| 237 |
+
e.messageStatusByte = messageTypes.text;
|
| 238 |
+
}
|
| 239 |
+
else
|
| 240 |
+
{
|
| 241 |
+
// add lyrics like a regular midi file
|
| 242 |
+
this.lyrics.push(e.messageData);
|
| 243 |
+
this.lyricsTicks.push(e.ticks);
|
| 244 |
+
break;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// kar: treat the same as text
|
| 248 |
+
// fallthrough
|
| 249 |
+
case messageTypes.text:
|
| 250 |
+
// possibly Soft Karaoke MIDI file
|
| 251 |
+
// it has a text event at the start of the file
|
| 252 |
+
// "@KMIDI KARAOKE FILE"
|
| 253 |
+
const checkedText = eventText.trim();
|
| 254 |
+
if (checkedText.startsWith("@KMIDI KARAOKE FILE"))
|
| 255 |
+
{
|
| 256 |
+
this.isKaraokeFile = true;
|
| 257 |
+
|
| 258 |
+
SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
|
| 259 |
+
}
|
| 260 |
+
else if (this.isKaraokeFile)
|
| 261 |
+
{
|
| 262 |
+
// check for @T (title)
|
| 263 |
+
// or @A because it is a title too sometimes?
|
| 264 |
+
// IDK it's strange
|
| 265 |
+
if (checkedText.startsWith("@T") || checkedText.startsWith("@A"))
|
| 266 |
+
{
|
| 267 |
+
if (!karaokeHasTitle)
|
| 268 |
+
{
|
| 269 |
+
this.midiName = checkedText.substring(2).trim();
|
| 270 |
+
karaokeHasTitle = true;
|
| 271 |
+
nameDetected = true;
|
| 272 |
+
// encode to rawMidiName
|
| 273 |
+
this.rawMidiName = getStringBytes(this.midiName);
|
| 274 |
+
}
|
| 275 |
+
else
|
| 276 |
+
{
|
| 277 |
+
// append to copyright
|
| 278 |
+
copyrightComponents.push(checkedText.substring(2).trim());
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
else if (checkedText[0] !== "@")
|
| 282 |
+
{
|
| 283 |
+
// non @: the lyrics
|
| 284 |
+
this.lyrics.push(sanitizeKarLyrics(e.messageData));
|
| 285 |
+
this.lyricsTicks.push(e.ticks);
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
break;
|
| 289 |
+
|
| 290 |
+
case messageTypes.trackName:
|
| 291 |
+
break;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
// add used channels
|
| 295 |
+
this.usedChannelsOnTrack.push(usedChannels);
|
| 296 |
+
|
| 297 |
+
// track name
|
| 298 |
+
this.trackNames[i] = "";
|
| 299 |
+
const trackName = track.find(e => e.messageStatusByte === messageTypes.trackName);
|
| 300 |
+
if (trackName)
|
| 301 |
+
{
|
| 302 |
+
trackName.messageData.currentIndex = 0;
|
| 303 |
+
const name = readBytesAsString(trackName.messageData, trackName.messageData.length);
|
| 304 |
+
this.trackNames[i] = name;
|
| 305 |
+
// If the track has no voice messages, its "track name" event (if it has any)
|
| 306 |
+
// is some metadata.
|
| 307 |
+
// Add it to copyright
|
| 308 |
+
if (!trackHasVoiceMessages)
|
| 309 |
+
{
|
| 310 |
+
copyrightComponents.push(name);
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// reverse the tempo changes
|
| 316 |
+
this.tempoChanges.reverse();
|
| 317 |
+
|
| 318 |
+
SpessaSynthInfo(
|
| 319 |
+
`%cCorrecting loops, ports and detecting notes...`,
|
| 320 |
+
consoleColors.info
|
| 321 |
+
);
|
| 322 |
+
|
| 323 |
+
const firstNoteOns = [];
|
| 324 |
+
for (const t of this.tracks)
|
| 325 |
+
{
|
| 326 |
+
const firstNoteOn = t.find(e => (e.messageStatusByte & 0xF0) === messageTypes.noteOn);
|
| 327 |
+
if (firstNoteOn)
|
| 328 |
+
{
|
| 329 |
+
firstNoteOns.push(firstNoteOn.ticks);
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
this.firstNoteOn = Math.min(...firstNoteOns);
|
| 333 |
+
|
| 334 |
+
SpessaSynthInfo(
|
| 335 |
+
`%cFirst note-on detected at: %c${this.firstNoteOn}%c ticks!`,
|
| 336 |
+
consoleColors.info,
|
| 337 |
+
consoleColors.recognized,
|
| 338 |
+
consoleColors.info
|
| 339 |
+
);
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
if (loopStart !== null && loopEnd === null)
|
| 343 |
+
{
|
| 344 |
+
// not a loop
|
| 345 |
+
loopStart = this.firstNoteOn;
|
| 346 |
+
loopEnd = this.lastVoiceEventTick;
|
| 347 |
+
}
|
| 348 |
+
else
|
| 349 |
+
{
|
| 350 |
+
if (loopStart === null)
|
| 351 |
+
{
|
| 352 |
+
loopStart = this.firstNoteOn;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
if (loopEnd === null || loopEnd === 0)
|
| 356 |
+
{
|
| 357 |
+
loopEnd = this.lastVoiceEventTick;
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/**
|
| 362 |
+
*
|
| 363 |
+
* @type {{start: number, end: number}}
|
| 364 |
+
*/
|
| 365 |
+
this.loop = { start: loopStart, end: loopEnd };
|
| 366 |
+
|
| 367 |
+
SpessaSynthInfo(
|
| 368 |
+
`%cLoop points: start: %c${this.loop.start}%c end: %c${this.loop.end}`,
|
| 369 |
+
consoleColors.info,
|
| 370 |
+
consoleColors.recognized,
|
| 371 |
+
consoleColors.info,
|
| 372 |
+
consoleColors.recognized
|
| 373 |
+
);
|
| 374 |
+
|
| 375 |
+
// determine ports
|
| 376 |
+
let portOffset = 0;
|
| 377 |
+
this.midiPorts = [];
|
| 378 |
+
this.midiPortChannelOffsets = [];
|
| 379 |
+
for (let trackNum = 0; trackNum < this.tracks.length; trackNum++)
|
| 380 |
+
{
|
| 381 |
+
this.midiPorts.push(-1);
|
| 382 |
+
if (this.usedChannelsOnTrack[trackNum].size === 0)
|
| 383 |
+
{
|
| 384 |
+
continue;
|
| 385 |
+
}
|
| 386 |
+
for (const e of this.tracks[trackNum])
|
| 387 |
+
{
|
| 388 |
+
if (e.messageStatusByte !== messageTypes.midiPort)
|
| 389 |
+
{
|
| 390 |
+
continue;
|
| 391 |
+
}
|
| 392 |
+
const port = e.messageData[0];
|
| 393 |
+
this.midiPorts[trackNum] = port;
|
| 394 |
+
if (this.midiPortChannelOffsets[port] === undefined)
|
| 395 |
+
{
|
| 396 |
+
this.midiPortChannelOffsets[port] = portOffset;
|
| 397 |
+
portOffset += 16;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// fix midi ports:
|
| 403 |
+
// midi tracks without ports will have a value of -1
|
| 404 |
+
// if all ports have a value of -1, set it to 0,
|
| 405 |
+
// otherwise take the first midi port and replace all -1 with it,
|
| 406 |
+
// why would we do this?
|
| 407 |
+
// some midis (for some reason) specify all channels to port 1 or else,
|
| 408 |
+
// but leave the conductor track with no port pref.
|
| 409 |
+
// this spessasynth to reserve the first 16 channels for the conductor track
|
| 410 |
+
// (which doesn't play anything) and use the additional 16 for the actual ports.
|
| 411 |
+
let defaultPort = Infinity;
|
| 412 |
+
for (let port of this.midiPorts)
|
| 413 |
+
{
|
| 414 |
+
if (port !== -1)
|
| 415 |
+
{
|
| 416 |
+
if (defaultPort > port)
|
| 417 |
+
{
|
| 418 |
+
defaultPort = port;
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
if (defaultPort === Infinity)
|
| 423 |
+
{
|
| 424 |
+
defaultPort = 0;
|
| 425 |
+
}
|
| 426 |
+
this.midiPorts = this.midiPorts.map(port => port === -1 ? defaultPort : port);
|
| 427 |
+
// add fake port if empty
|
| 428 |
+
if (this.midiPortChannelOffsets.length === 0)
|
| 429 |
+
{
|
| 430 |
+
this.midiPortChannelOffsets = [0];
|
| 431 |
+
}
|
| 432 |
+
if (this.midiPortChannelOffsets.length < 2)
|
| 433 |
+
{
|
| 434 |
+
SpessaSynthInfo(`%cNo additional MIDI Ports detected.`, consoleColors.info);
|
| 435 |
+
}
|
| 436 |
+
else
|
| 437 |
+
{
|
| 438 |
+
this.isMultiPort = true;
|
| 439 |
+
SpessaSynthInfo(`%cMIDI Ports detected!`, consoleColors.recognized);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// midi name
|
| 443 |
+
if (!nameDetected)
|
| 444 |
+
{
|
| 445 |
+
if (this.tracks.length > 1)
|
| 446 |
+
{
|
| 447 |
+
// if more than 1 track and the first track has no notes,
|
| 448 |
+
// just find the first trackName in the first track.
|
| 449 |
+
if (
|
| 450 |
+
this.tracks[0].find(
|
| 451 |
+
message => message.messageStatusByte >= messageTypes.noteOn
|
| 452 |
+
&&
|
| 453 |
+
message.messageStatusByte < messageTypes.polyPressure
|
| 454 |
+
) === undefined
|
| 455 |
+
)
|
| 456 |
+
{
|
| 457 |
+
|
| 458 |
+
let name = this.tracks[0].find(message => message.messageStatusByte === messageTypes.trackName);
|
| 459 |
+
if (name)
|
| 460 |
+
{
|
| 461 |
+
this.rawMidiName = name.messageData;
|
| 462 |
+
name.messageData.currentIndex = 0;
|
| 463 |
+
this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
else
|
| 468 |
+
{
|
| 469 |
+
// if only 1 track, find the first "track name" event
|
| 470 |
+
let name = this.tracks[0].find(message => message.messageStatusByte === messageTypes.trackName);
|
| 471 |
+
if (name)
|
| 472 |
+
{
|
| 473 |
+
this.rawMidiName = name.messageData;
|
| 474 |
+
name.messageData.currentIndex = 0;
|
| 475 |
+
this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
if (!copyrightDetected)
|
| 481 |
+
{
|
| 482 |
+
this.copyright = copyrightComponents
|
| 483 |
+
// trim and group newlines into one
|
| 484 |
+
.map(c => c.trim().replace(/(\r?\n)+/g, "\n"))
|
| 485 |
+
// remove empty strings
|
| 486 |
+
.filter(c => c.length > 0)
|
| 487 |
+
// join with newlines
|
| 488 |
+
.join("\n") || "";
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
this.midiName = this.midiName.trim();
|
| 492 |
+
this.midiNameUsesFileName = false;
|
| 493 |
+
// if midiName is "", use the file name
|
| 494 |
+
if (this.midiName.length === 0)
|
| 495 |
+
{
|
| 496 |
+
SpessaSynthInfo(
|
| 497 |
+
`%cNo name detected. Using the alt name!`,
|
| 498 |
+
consoleColors.info
|
| 499 |
+
);
|
| 500 |
+
this.midiName = formatTitle(this.fileName);
|
| 501 |
+
this.midiNameUsesFileName = true;
|
| 502 |
+
// encode it too
|
| 503 |
+
this.rawMidiName = new Uint8Array(this.midiName.length);
|
| 504 |
+
for (let i = 0; i < this.midiName.length; i++)
|
| 505 |
+
{
|
| 506 |
+
this.rawMidiName[i] = this.midiName.charCodeAt(i);
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
else
|
| 510 |
+
{
|
| 511 |
+
SpessaSynthInfo(
|
| 512 |
+
`%cMIDI Name detected! %c"${this.midiName}"`,
|
| 513 |
+
consoleColors.info,
|
| 514 |
+
consoleColors.recognized
|
| 515 |
+
);
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// if the first event is not at 0 ticks, add a track name
|
| 519 |
+
// https://github.com/spessasus/SpessaSynth/issues/145
|
| 520 |
+
if (!this.tracks.some(t => t[0].ticks === 0))
|
| 521 |
+
{
|
| 522 |
+
const track = this.tracks[0];
|
| 523 |
+
// can copy
|
| 524 |
+
track.unshift(new MIDIMessage(
|
| 525 |
+
0,
|
| 526 |
+
messageTypes.trackName,
|
| 527 |
+
new IndexedByteArray(this.rawMidiName.buffer)
|
| 528 |
+
));
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
/**
|
| 533 |
+
* The total playback time, in seconds
|
| 534 |
+
* @type {number}
|
| 535 |
+
*/
|
| 536 |
+
this.duration = this.MIDIticksToSeconds(this.lastVoiceEventTick);
|
| 537 |
+
|
| 538 |
+
SpessaSynthInfo("%cSuccess!", consoleColors.recognized);
|
| 539 |
+
SpessaSynthGroupEnd();
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
/**
|
| 543 |
+
* Updates all internal values
|
| 544 |
+
*/
|
| 545 |
+
flush()
|
| 546 |
+
{
|
| 547 |
+
|
| 548 |
+
for (const t of this.tracks)
|
| 549 |
+
{
|
| 550 |
+
// sort the track by ticks
|
| 551 |
+
t.sort((e1, e2) => e1.ticks - e2.ticks);
|
| 552 |
+
}
|
| 553 |
+
this._parseInternal();
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
BasicMIDI.prototype.writeMIDI = writeMIDI;
|
| 558 |
+
BasicMIDI.prototype.modifyMIDI = modifyMIDI;
|
| 559 |
+
BasicMIDI.prototype.applySnapshotToMIDI = applySnapshotToMIDI;
|
| 560 |
+
BasicMIDI.prototype.writeRMIDI = writeRMIDI;
|
| 561 |
+
BasicMIDI.prototype.getUsedProgramsAndKeys = getUsedProgramsAndKeys;
|
| 562 |
+
|
| 563 |
+
export { BasicMIDI };
|
spessasynth_lib/midi_parser/midi_builder.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BasicMIDI } from "./basic_midi.js";
|
| 2 |
+
import { messageTypes, MIDIMessage } from "./midi_message.js";
|
| 3 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 4 |
+
import { SpessaSynthWarn } from "../utils/loggin.js";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* A class that helps to build a MIDI file from scratch.
|
| 8 |
+
*/
|
| 9 |
+
export class MIDIBuilder extends BasicMIDI
|
| 10 |
+
{
|
| 11 |
+
/**
|
| 12 |
+
* @param name {string} The MIDI's name
|
| 13 |
+
* @param timeDivision {number} the file's time division
|
| 14 |
+
* @param initialTempo {number} the file's initial tempo
|
| 15 |
+
*/
|
| 16 |
+
constructor(name, timeDivision = 480, initialTempo = 120)
|
| 17 |
+
{
|
| 18 |
+
super();
|
| 19 |
+
this.timeDivision = timeDivision;
|
| 20 |
+
this.midiName = name;
|
| 21 |
+
this.encoder = new TextEncoder();
|
| 22 |
+
this.rawMidiName = this.encoder.encode(name);
|
| 23 |
+
|
| 24 |
+
// create the first track with the file name
|
| 25 |
+
this.addNewTrack(name);
|
| 26 |
+
this.addSetTempo(0, initialTempo);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Adds a new Set Tempo event
|
| 31 |
+
* @param ticks {number} the tick number of the event
|
| 32 |
+
* @param tempo {number} the tempo in beats per minute (BPM)
|
| 33 |
+
*/
|
| 34 |
+
addSetTempo(ticks, tempo)
|
| 35 |
+
{
|
| 36 |
+
const array = new IndexedByteArray(3);
|
| 37 |
+
|
| 38 |
+
tempo = 60000000 / tempo;
|
| 39 |
+
|
| 40 |
+
// Extract each byte in big-endian order
|
| 41 |
+
array[0] = (tempo >> 16) & 0xFF;
|
| 42 |
+
array[1] = (tempo >> 8) & 0xFF;
|
| 43 |
+
array[2] = tempo & 0xFF;
|
| 44 |
+
|
| 45 |
+
this.addEvent(ticks, 0, messageTypes.setTempo, array);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Adds a new MIDI track
|
| 50 |
+
* @param name {string} the new track's name
|
| 51 |
+
* @param port {number} the new track's port
|
| 52 |
+
*/
|
| 53 |
+
addNewTrack(name, port = 0)
|
| 54 |
+
{
|
| 55 |
+
this.tracksAmount++;
|
| 56 |
+
if (this.tracksAmount > 1)
|
| 57 |
+
{
|
| 58 |
+
this.format = 1;
|
| 59 |
+
}
|
| 60 |
+
this.tracks.push([]);
|
| 61 |
+
this.tracks[this.tracksAmount - 1].push(
|
| 62 |
+
new MIDIMessage(0, messageTypes.endOfTrack, new IndexedByteArray(0))
|
| 63 |
+
);
|
| 64 |
+
this.addEvent(0, this.tracksAmount - 1, messageTypes.trackName, this.encoder.encode(name));
|
| 65 |
+
this.addEvent(0, this.tracksAmount - 1, messageTypes.midiPort, [port]);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Adds a new MIDI Event
|
| 70 |
+
* @param ticks {number} the tick time of the event
|
| 71 |
+
* @param track {number} the track number to use
|
| 72 |
+
* @param event {number} the MIDI event number
|
| 73 |
+
* @param eventData {Uint8Array|Iterable<number>} the raw event data
|
| 74 |
+
*/
|
| 75 |
+
addEvent(ticks, track, event, eventData)
|
| 76 |
+
{
|
| 77 |
+
if (!this.tracks[track])
|
| 78 |
+
{
|
| 79 |
+
throw new Error(`Track ${track} does not exist. Add it via addTrack method.`);
|
| 80 |
+
}
|
| 81 |
+
if (event === messageTypes.endOfTrack)
|
| 82 |
+
{
|
| 83 |
+
SpessaSynthWarn(
|
| 84 |
+
"The EndOfTrack is added automatically and does not influence the duration. Consider adding a voice event instead.");
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
// remove the end of track
|
| 88 |
+
this.tracks[track].pop();
|
| 89 |
+
this.tracks[track].push(new MIDIMessage(
|
| 90 |
+
ticks,
|
| 91 |
+
event,
|
| 92 |
+
new IndexedByteArray(eventData)
|
| 93 |
+
));
|
| 94 |
+
// add the end of track
|
| 95 |
+
this.tracks[track].push(new MIDIMessage(
|
| 96 |
+
ticks,
|
| 97 |
+
messageTypes.endOfTrack,
|
| 98 |
+
new IndexedByteArray(0)
|
| 99 |
+
));
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Adds a new Note On event
|
| 104 |
+
* @param ticks {number} the tick time of the event
|
| 105 |
+
* @param track {number} the track number to use
|
| 106 |
+
* @param channel {number} the channel to use
|
| 107 |
+
* @param midiNote {number} the midi note of the keypress
|
| 108 |
+
* @param velocity {number} the velocity of the keypress
|
| 109 |
+
*/
|
| 110 |
+
addNoteOn(ticks, track, channel, midiNote, velocity)
|
| 111 |
+
{
|
| 112 |
+
channel %= 16;
|
| 113 |
+
midiNote %= 128;
|
| 114 |
+
velocity %= 128;
|
| 115 |
+
this.addEvent(
|
| 116 |
+
ticks,
|
| 117 |
+
track,
|
| 118 |
+
messageTypes.noteOn | channel,
|
| 119 |
+
[midiNote, velocity]
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Adds a new Note Off event
|
| 125 |
+
* @param ticks {number} the tick time of the event
|
| 126 |
+
* @param track {number} the track number to use
|
| 127 |
+
* @param channel {number} the channel to use
|
| 128 |
+
* @param midiNote {number} the midi note of the key release
|
| 129 |
+
*/
|
| 130 |
+
addNoteOff(ticks, track, channel, midiNote)
|
| 131 |
+
{
|
| 132 |
+
channel %= 16;
|
| 133 |
+
midiNote %= 128;
|
| 134 |
+
this.addEvent(
|
| 135 |
+
ticks,
|
| 136 |
+
track,
|
| 137 |
+
messageTypes.noteOff | channel,
|
| 138 |
+
[midiNote, 64]
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Adds a new Program Change event
|
| 144 |
+
* @param ticks {number} the tick time of the event
|
| 145 |
+
* @param track {number} the track number to use
|
| 146 |
+
* @param channel {number} the channel to use
|
| 147 |
+
* @param programNumber {number} the MIDI program to use
|
| 148 |
+
*/
|
| 149 |
+
addProgramChange(ticks, track, channel, programNumber)
|
| 150 |
+
{
|
| 151 |
+
channel %= 16;
|
| 152 |
+
programNumber %= 128;
|
| 153 |
+
this.addEvent(
|
| 154 |
+
ticks,
|
| 155 |
+
track,
|
| 156 |
+
messageTypes.programChange | channel,
|
| 157 |
+
[programNumber]
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Adds a new Controller Change event
|
| 163 |
+
* @param ticks {number} the tick time of the event
|
| 164 |
+
* @param track {number} the track number to use
|
| 165 |
+
* @param channel {number} the channel to use
|
| 166 |
+
* @param controllerNumber {number} the MIDI CC to use
|
| 167 |
+
* @param controllerValue {number} the new CC value
|
| 168 |
+
*/
|
| 169 |
+
addControllerChange(ticks, track, channel, controllerNumber, controllerValue)
|
| 170 |
+
{
|
| 171 |
+
channel %= 16;
|
| 172 |
+
controllerNumber %= 128;
|
| 173 |
+
controllerValue %= 128;
|
| 174 |
+
this.addEvent(
|
| 175 |
+
ticks,
|
| 176 |
+
track,
|
| 177 |
+
messageTypes.controllerChange | channel,
|
| 178 |
+
[controllerNumber, controllerValue]
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Adds a new Pitch Wheel event
|
| 184 |
+
* @param ticks {number} the tick time of the event
|
| 185 |
+
* @param track {number} the track to use
|
| 186 |
+
* @param channel {number} the channel to use
|
| 187 |
+
* @param MSB {number} SECOND byte of the MIDI pitchWheel message
|
| 188 |
+
* @param LSB {number} FIRST byte of the MIDI pitchWheel message
|
| 189 |
+
*/
|
| 190 |
+
addPitchWheel(ticks, track, channel, MSB, LSB)
|
| 191 |
+
{
|
| 192 |
+
channel %= 16;
|
| 193 |
+
MSB %= 128;
|
| 194 |
+
LSB %= 128;
|
| 195 |
+
this.addEvent(
|
| 196 |
+
ticks,
|
| 197 |
+
track,
|
| 198 |
+
messageTypes.pitchBend | channel,
|
| 199 |
+
[LSB, MSB]
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
}
|
spessasynth_lib/midi_parser/midi_data.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MIDISequenceData } from "./midi_sequence.js";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* A simplified version of the MIDI, accessible at all times from the Sequencer.
|
| 5 |
+
* Use getMIDI() to get the actual sequence.
|
| 6 |
+
* This class contains all properties that MIDI does, except for tracks and the embedded soundfont.
|
| 7 |
+
*/
|
| 8 |
+
export class MIDIData extends MIDISequenceData
|
| 9 |
+
{
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* A boolean indicating if the MIDI file contains an embedded soundfont.
|
| 13 |
+
* If the embedded soundfont is undefined, this will be false.
|
| 14 |
+
* @type {boolean}
|
| 15 |
+
*/
|
| 16 |
+
isEmbedded = false;
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Constructor that copies data from a BasicMIDI instance.
|
| 20 |
+
* @param {BasicMIDI} midi - The BasicMIDI instance to copy data from.
|
| 21 |
+
*/
|
| 22 |
+
constructor(midi)
|
| 23 |
+
{
|
| 24 |
+
super();
|
| 25 |
+
this._copyFromSequence(midi);
|
| 26 |
+
|
| 27 |
+
// Set isEmbedded based on the presence of an embeddedSoundFont
|
| 28 |
+
this.isEmbedded = midi.embeddedSoundFont !== undefined;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Temporary MIDI data used when the MIDI is not loaded.
|
| 35 |
+
* @type {MIDIData}
|
| 36 |
+
*/
|
| 37 |
+
export const DUMMY_MIDI_DATA = {
|
| 38 |
+
duration: 99999,
|
| 39 |
+
firstNoteOn: 0,
|
| 40 |
+
loop: {
|
| 41 |
+
start: 0,
|
| 42 |
+
end: 123456
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
lastVoiceEventTick: 123456,
|
| 46 |
+
lyrics: [],
|
| 47 |
+
copyright: "",
|
| 48 |
+
midiPorts: [],
|
| 49 |
+
midiPortChannelOffsets: [],
|
| 50 |
+
tracksAmount: 0,
|
| 51 |
+
tempoChanges: [{ ticks: 0, tempo: 120 }],
|
| 52 |
+
fileName: "NOT_LOADED.mid",
|
| 53 |
+
midiName: "Loading...",
|
| 54 |
+
rawMidiName: new Uint8Array([76, 111, 97, 100, 105, 110, 103, 46, 46, 46]), // "Loading..."
|
| 55 |
+
usedChannelsOnTrack: [],
|
| 56 |
+
timeDivision: 0,
|
| 57 |
+
keyRange: { min: 0, max: 127 },
|
| 58 |
+
isEmbedded: false,
|
| 59 |
+
RMIDInfo: {},
|
| 60 |
+
bankOffset: 0,
|
| 61 |
+
midiNameUsesFileName: false,
|
| 62 |
+
format: 0
|
| 63 |
+
};
|
spessasynth_lib/midi_parser/midi_editor.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { messageTypes, midiControllers, MIDIMessage } from "./midi_message.js";
|
| 2 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 3 |
+
import { SpessaSynthGroupCollapsed, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
|
| 4 |
+
import { consoleColors } from "../utils/other.js";
|
| 5 |
+
|
| 6 |
+
import { customControllers } from "../synthetizer/worklet_system/worklet_utilities/controller_tables.js";
|
| 7 |
+
import { DEFAULT_PERCUSSION } from "../synthetizer/synth_constants.js";
|
| 8 |
+
import { isGM2On, isGMOn, isGSOn, isXGOn } from "../utils/sysex_detector.js";
|
| 9 |
+
import { isSystemXG, isXGDrums, XG_SFX_VOICE } from "../utils/xg_hacks.js";
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* @param ticks {number}
|
| 13 |
+
* @returns {MIDIMessage}
|
| 14 |
+
*/
|
| 15 |
+
export function getGsOn(ticks)
|
| 16 |
+
{
|
| 17 |
+
return new MIDIMessage(
|
| 18 |
+
ticks,
|
| 19 |
+
messageTypes.systemExclusive,
|
| 20 |
+
new IndexedByteArray([
|
| 21 |
+
0x41, // Roland
|
| 22 |
+
0x10, // Device ID (defaults to 16 on roland)
|
| 23 |
+
0x42, // GS
|
| 24 |
+
0x12, // Command ID (DT1) (whatever that means...)
|
| 25 |
+
0x40, // System parameter - Address
|
| 26 |
+
0x00, // Global parameter - Address
|
| 27 |
+
0x7F, // GS Change - Address
|
| 28 |
+
0x00, // turn on - Data
|
| 29 |
+
0x41, // checksum
|
| 30 |
+
0xF7 // end of exclusive
|
| 31 |
+
])
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* @param channel {number}
|
| 37 |
+
* @param cc {number}
|
| 38 |
+
* @param value {number}
|
| 39 |
+
* @param ticks {number}
|
| 40 |
+
* @returns {MIDIMessage}
|
| 41 |
+
*/
|
| 42 |
+
function getControllerChange(channel, cc, value, ticks)
|
| 43 |
+
{
|
| 44 |
+
return new MIDIMessage(
|
| 45 |
+
ticks,
|
| 46 |
+
messageTypes.controllerChange | (channel % 16),
|
| 47 |
+
new IndexedByteArray([cc, value])
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* @param channel {number}
|
| 53 |
+
* @param ticks {number}
|
| 54 |
+
* @returns {MIDIMessage}
|
| 55 |
+
*/
|
| 56 |
+
function getDrumChange(channel, ticks)
|
| 57 |
+
{
|
| 58 |
+
const chanAddress = 0x10 | [1, 2, 3, 4, 5, 6, 7, 8, 0, 9, 10, 11, 12, 13, 14, 15][channel % 16];
|
| 59 |
+
// excluding manufacturerID DeviceID and ModelID (and F7)
|
| 60 |
+
const sysexData = [
|
| 61 |
+
0x41, // Roland
|
| 62 |
+
0x10, // Device ID (defaults to 16 on roland)
|
| 63 |
+
0x42, // GS
|
| 64 |
+
0x12, // Command ID (DT1) (whatever that means...)
|
| 65 |
+
0x40, // System parameter }
|
| 66 |
+
chanAddress, // Channel parameter } Address
|
| 67 |
+
0x15, // Drum change }
|
| 68 |
+
0x01 // Is Drums } Data
|
| 69 |
+
];
|
| 70 |
+
// calculate checksum
|
| 71 |
+
// https://cdn.roland.com/assets/media/pdf/F-20_MIDI_Imple_e01_W.pdf section 4
|
| 72 |
+
const sum = 0x40 + chanAddress + 0x15 + 0x01;
|
| 73 |
+
const checksum = 128 - (sum % 128);
|
| 74 |
+
// add system exclusive to enable drums
|
| 75 |
+
return new MIDIMessage(
|
| 76 |
+
ticks,
|
| 77 |
+
messageTypes.systemExclusive,
|
| 78 |
+
new IndexedByteArray([
|
| 79 |
+
...sysexData,
|
| 80 |
+
checksum,
|
| 81 |
+
0xF7
|
| 82 |
+
])
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* @typedef {Object} DesiredProgramChange
|
| 88 |
+
* @property {number} channel - The channel number.
|
| 89 |
+
* @property {number} program - The program number.
|
| 90 |
+
* @property {number} bank - The bank number.
|
| 91 |
+
* @property {boolean} isDrum - Indicates if the channel is a drum channel.
|
| 92 |
+
* If it is, then the bank number is ignored.
|
| 93 |
+
*/
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* @typedef {Object} DesiredControllerChange
|
| 97 |
+
* @property {number} channel - The channel number.
|
| 98 |
+
* @property {number} controllerNumber - The MIDI controller number.
|
| 99 |
+
* @property {number} controllerValue - The new controller value.
|
| 100 |
+
*/
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* @typedef {Object} DesiredChanneltranspose
|
| 104 |
+
* @property {number} channel - The channel number.
|
| 105 |
+
* @property {number} keyShift - The number of semitones to transpose.
|
| 106 |
+
* Note that this can use floating point numbers,
|
| 107 |
+
* which will be used to fine-tune the pitch in cents using RPN.
|
| 108 |
+
*/
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Allows easy editing of the file by removing channels, changing programs,
|
| 113 |
+
* changing controllers and transposing channels. Note that this modifies the MIDI in-place.
|
| 114 |
+
*
|
| 115 |
+
* @this {BasicMIDI}
|
| 116 |
+
* @param {DesiredProgramChange[]} desiredProgramChanges - The programs to set on given channels.
|
| 117 |
+
* @param {DesiredControllerChange[]} desiredControllerChanges - The controllers to set on given channels.
|
| 118 |
+
* @param {number[]} desiredChannelsToClear - The channels to remove from the sequence.
|
| 119 |
+
* @param {DesiredChanneltranspose[]} desiredChannelsToTranspose - The channels to transpose.
|
| 120 |
+
*/
|
| 121 |
+
export function modifyMIDI(
|
| 122 |
+
desiredProgramChanges = [],
|
| 123 |
+
desiredControllerChanges = [],
|
| 124 |
+
desiredChannelsToClear = [],
|
| 125 |
+
desiredChannelsToTranspose = []
|
| 126 |
+
)
|
| 127 |
+
{
|
| 128 |
+
const midi = this;
|
| 129 |
+
SpessaSynthGroupCollapsed("%cApplying changes to the MIDI file...", consoleColors.info);
|
| 130 |
+
|
| 131 |
+
SpessaSynthInfo("Desired program changes:", desiredProgramChanges);
|
| 132 |
+
SpessaSynthInfo("Desired CC changes:", desiredControllerChanges);
|
| 133 |
+
SpessaSynthInfo("Desired channels to clear:", desiredChannelsToClear);
|
| 134 |
+
SpessaSynthInfo("Desired channels to transpose:", desiredChannelsToTranspose);
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* @type {Set<number>}
|
| 138 |
+
*/
|
| 139 |
+
const channelsToChangeProgram = new Set();
|
| 140 |
+
desiredProgramChanges.forEach(c =>
|
| 141 |
+
{
|
| 142 |
+
channelsToChangeProgram.add(c.channel);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
// go through all events one by one
|
| 147 |
+
let system = "gs";
|
| 148 |
+
let addedGs = false;
|
| 149 |
+
/**
|
| 150 |
+
* indexes for tracks
|
| 151 |
+
* @type {number[]}
|
| 152 |
+
*/
|
| 153 |
+
const eventIndexes = Array(midi.tracks.length).fill(0);
|
| 154 |
+
let remainingTracks = midi.tracks.length;
|
| 155 |
+
|
| 156 |
+
function findFirstEventIndex()
|
| 157 |
+
{
|
| 158 |
+
let index = 0;
|
| 159 |
+
let ticks = Infinity;
|
| 160 |
+
midi.tracks.forEach((track, i) =>
|
| 161 |
+
{
|
| 162 |
+
if (eventIndexes[i] >= track.length)
|
| 163 |
+
{
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
if (track[eventIndexes[i]].ticks < ticks)
|
| 167 |
+
{
|
| 168 |
+
index = i;
|
| 169 |
+
ticks = track[eventIndexes[i]].ticks;
|
| 170 |
+
}
|
| 171 |
+
});
|
| 172 |
+
return index;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// it copies midiPorts everywhere else, but here 0 works so DO NOT CHANGE!
|
| 176 |
+
/**
|
| 177 |
+
* midi port number for the corresponding track
|
| 178 |
+
* @type {number[]}
|
| 179 |
+
*/
|
| 180 |
+
const midiPorts = midi.midiPorts.slice();
|
| 181 |
+
/**
|
| 182 |
+
* midi port: channel offset
|
| 183 |
+
* @type {Object<number, number>}
|
| 184 |
+
*/
|
| 185 |
+
const midiPortChannelOffsets = {};
|
| 186 |
+
let midiPortChannelOffset = 0;
|
| 187 |
+
|
| 188 |
+
function assignMIDIPort(trackNum, port)
|
| 189 |
+
{
|
| 190 |
+
// do not assign ports to empty tracks
|
| 191 |
+
if (midi.usedChannelsOnTrack[trackNum].size === 0)
|
| 192 |
+
{
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// assign new 16 channels if the port is not occupied yet
|
| 197 |
+
if (midiPortChannelOffset === 0)
|
| 198 |
+
{
|
| 199 |
+
midiPortChannelOffset += 16;
|
| 200 |
+
midiPortChannelOffsets[port] = 0;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
if (midiPortChannelOffsets[port] === undefined)
|
| 204 |
+
{
|
| 205 |
+
midiPortChannelOffsets[port] = midiPortChannelOffset;
|
| 206 |
+
midiPortChannelOffset += 16;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
midiPorts[trackNum] = port;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// assign port offsets
|
| 213 |
+
midi.midiPorts.forEach((port, trackIndex) =>
|
| 214 |
+
{
|
| 215 |
+
assignMIDIPort(trackIndex, port);
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
const channelsAmount = midiPortChannelOffset;
|
| 219 |
+
/**
|
| 220 |
+
* Tracks if the channel already had its first note on
|
| 221 |
+
* @type {boolean[]}
|
| 222 |
+
*/
|
| 223 |
+
const isFirstNoteOn = Array(channelsAmount).fill(true);
|
| 224 |
+
|
| 225 |
+
/**
|
| 226 |
+
* MIDI key transpose
|
| 227 |
+
* @type {number[]}
|
| 228 |
+
*/
|
| 229 |
+
const coarseTranspose = Array(channelsAmount).fill(0);
|
| 230 |
+
/**
|
| 231 |
+
* RPN fine transpose
|
| 232 |
+
* @type {number[]}
|
| 233 |
+
*/
|
| 234 |
+
const fineTranspose = Array(channelsAmount).fill(0);
|
| 235 |
+
desiredChannelsToTranspose.forEach(transpose =>
|
| 236 |
+
{
|
| 237 |
+
const coarse = Math.trunc(transpose.keyShift);
|
| 238 |
+
const fine = transpose.keyShift - coarse;
|
| 239 |
+
coarseTranspose[transpose.channel] = coarse;
|
| 240 |
+
fineTranspose[transpose.channel] = fine;
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
while (remainingTracks > 0)
|
| 244 |
+
{
|
| 245 |
+
let trackNum = findFirstEventIndex();
|
| 246 |
+
const track = midi.tracks[trackNum];
|
| 247 |
+
if (eventIndexes[trackNum] >= track.length)
|
| 248 |
+
{
|
| 249 |
+
remainingTracks--;
|
| 250 |
+
continue;
|
| 251 |
+
}
|
| 252 |
+
const index = eventIndexes[trackNum]++;
|
| 253 |
+
const e = track[index];
|
| 254 |
+
|
| 255 |
+
const deleteThisEvent = () =>
|
| 256 |
+
{
|
| 257 |
+
track.splice(index, 1);
|
| 258 |
+
eventIndexes[trackNum]--;
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
/**
|
| 262 |
+
* @param e {MIDIMessage}
|
| 263 |
+
* @param offset{number}
|
| 264 |
+
*/
|
| 265 |
+
const addEventBefore = (e, offset = 0) =>
|
| 266 |
+
{
|
| 267 |
+
track.splice(index + offset, 0, e);
|
| 268 |
+
eventIndexes[trackNum]++;
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
let portOffset = midiPortChannelOffsets[midiPorts[trackNum]] || 0;
|
| 273 |
+
if (e.messageStatusByte === messageTypes.midiPort)
|
| 274 |
+
{
|
| 275 |
+
assignMIDIPort(trackNum, e.messageData[0]);
|
| 276 |
+
continue;
|
| 277 |
+
}
|
| 278 |
+
// don't clear meta
|
| 279 |
+
if (e.messageStatusByte <= messageTypes.sequenceSpecific && e.messageStatusByte >= messageTypes.sequenceNumber)
|
| 280 |
+
{
|
| 281 |
+
continue;
|
| 282 |
+
}
|
| 283 |
+
const status = e.messageStatusByte & 0xF0;
|
| 284 |
+
const midiChannel = e.messageStatusByte & 0xF;
|
| 285 |
+
const channel = midiChannel + portOffset;
|
| 286 |
+
// clear channel?
|
| 287 |
+
if (desiredChannelsToClear.indexOf(channel) !== -1)
|
| 288 |
+
{
|
| 289 |
+
deleteThisEvent();
|
| 290 |
+
continue;
|
| 291 |
+
}
|
| 292 |
+
switch (status)
|
| 293 |
+
{
|
| 294 |
+
case messageTypes.noteOn:
|
| 295 |
+
// is it first?
|
| 296 |
+
if (isFirstNoteOn[channel])
|
| 297 |
+
{
|
| 298 |
+
isFirstNoteOn[channel] = false;
|
| 299 |
+
// all right, so this is the first note on
|
| 300 |
+
// first: controllers
|
| 301 |
+
// because FSMP does not like program changes after cc changes in embedded midis
|
| 302 |
+
// and since we use splice,
|
| 303 |
+
// controllers get added first, then programs before them
|
| 304 |
+
// now add controllers
|
| 305 |
+
desiredControllerChanges.filter(c => c.channel === channel).forEach(change =>
|
| 306 |
+
{
|
| 307 |
+
const ccChange = getControllerChange(
|
| 308 |
+
midiChannel,
|
| 309 |
+
change.controllerNumber,
|
| 310 |
+
change.controllerValue,
|
| 311 |
+
e.ticks
|
| 312 |
+
);
|
| 313 |
+
addEventBefore(ccChange);
|
| 314 |
+
});
|
| 315 |
+
const fineTune = fineTranspose[channel];
|
| 316 |
+
|
| 317 |
+
if (fineTune !== 0)
|
| 318 |
+
{
|
| 319 |
+
// add rpn
|
| 320 |
+
// 64 is the center, 96 = 50 cents up
|
| 321 |
+
const centsCoarse = (fineTune * 64) + 64;
|
| 322 |
+
const rpnCoarse = getControllerChange(midiChannel, midiControllers.RPNMsb, 0, e.ticks);
|
| 323 |
+
const rpnFine = getControllerChange(midiChannel, midiControllers.RPNLsb, 1, e.ticks);
|
| 324 |
+
const dataEntryCoarse = getControllerChange(
|
| 325 |
+
channel,
|
| 326 |
+
midiControllers.dataEntryMsb,
|
| 327 |
+
centsCoarse,
|
| 328 |
+
e.ticks
|
| 329 |
+
);
|
| 330 |
+
const dataEntryFine = getControllerChange(
|
| 331 |
+
midiChannel,
|
| 332 |
+
midiControllers.lsbForControl6DataEntry,
|
| 333 |
+
0,
|
| 334 |
+
e.ticks
|
| 335 |
+
);
|
| 336 |
+
addEventBefore(dataEntryFine);
|
| 337 |
+
addEventBefore(dataEntryCoarse);
|
| 338 |
+
addEventBefore(rpnFine);
|
| 339 |
+
addEventBefore(rpnCoarse);
|
| 340 |
+
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
if (channelsToChangeProgram.has(channel))
|
| 344 |
+
{
|
| 345 |
+
const change = desiredProgramChanges.find(c => c.channel === channel);
|
| 346 |
+
let desiredBank = Math.max(0, Math.min(change.bank, 127));
|
| 347 |
+
const desiredProgram = change.program;
|
| 348 |
+
SpessaSynthInfo(
|
| 349 |
+
`%cSetting %c${change.channel}%c to %c${desiredBank}:${desiredProgram}%c. Track num: %c${trackNum}`,
|
| 350 |
+
consoleColors.info,
|
| 351 |
+
consoleColors.recognized,
|
| 352 |
+
consoleColors.info,
|
| 353 |
+
consoleColors.recognized,
|
| 354 |
+
consoleColors.info,
|
| 355 |
+
consoleColors.recognized
|
| 356 |
+
);
|
| 357 |
+
|
| 358 |
+
// note: this is in reverse.
|
| 359 |
+
// the output event order is: drums -> lsb -> msb -> program change
|
| 360 |
+
|
| 361 |
+
// add program change
|
| 362 |
+
const programChange = new MIDIMessage(
|
| 363 |
+
e.ticks,
|
| 364 |
+
messageTypes.programChange | midiChannel,
|
| 365 |
+
new IndexedByteArray([
|
| 366 |
+
desiredProgram
|
| 367 |
+
])
|
| 368 |
+
);
|
| 369 |
+
addEventBefore(programChange);
|
| 370 |
+
|
| 371 |
+
const addBank = (isLSB, v) =>
|
| 372 |
+
{
|
| 373 |
+
const bankChange = getControllerChange(
|
| 374 |
+
midiChannel,
|
| 375 |
+
isLSB ? midiControllers.lsbForControl0BankSelect : midiControllers.bankSelect,
|
| 376 |
+
v,
|
| 377 |
+
e.ticks
|
| 378 |
+
);
|
| 379 |
+
addEventBefore(bankChange);
|
| 380 |
+
};
|
| 381 |
+
|
| 382 |
+
// on xg, add lsb
|
| 383 |
+
if (isSystemXG(system))
|
| 384 |
+
{
|
| 385 |
+
// xg drums: msb can be 120, 126 or 127
|
| 386 |
+
if (change.isDrum)
|
| 387 |
+
{
|
| 388 |
+
SpessaSynthInfo(
|
| 389 |
+
`%cAdding XG Drum change on track %c${trackNum}`,
|
| 390 |
+
consoleColors.recognized,
|
| 391 |
+
consoleColors.value
|
| 392 |
+
);
|
| 393 |
+
addBank(false, isXGDrums(desiredBank) ? desiredBank : 127);
|
| 394 |
+
addBank(true, 0);
|
| 395 |
+
}
|
| 396 |
+
else
|
| 397 |
+
{
|
| 398 |
+
// sfx voice is set via MSB
|
| 399 |
+
if (desiredBank === XG_SFX_VOICE)
|
| 400 |
+
{
|
| 401 |
+
addBank(false, XG_SFX_VOICE);
|
| 402 |
+
addBank(true, 0);
|
| 403 |
+
}
|
| 404 |
+
else
|
| 405 |
+
{
|
| 406 |
+
// add variation as LSB
|
| 407 |
+
addBank(false, 0);
|
| 408 |
+
addBank(true, desiredBank);
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
else
|
| 413 |
+
{
|
| 414 |
+
// add just msb
|
| 415 |
+
addBank(false, desiredBank);
|
| 416 |
+
|
| 417 |
+
if (change.isDrum && midiChannel !== DEFAULT_PERCUSSION)
|
| 418 |
+
{
|
| 419 |
+
// add gs drum change
|
| 420 |
+
SpessaSynthInfo(
|
| 421 |
+
`%cAdding GS Drum change on track %c${trackNum}`,
|
| 422 |
+
consoleColors.recognized,
|
| 423 |
+
consoleColors.value
|
| 424 |
+
);
|
| 425 |
+
addEventBefore(getDrumChange(midiChannel, e.ticks));
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
}
|
| 430 |
+
// transpose key (for zero it won't change anyway)
|
| 431 |
+
e.messageData[0] += coarseTranspose[channel];
|
| 432 |
+
break;
|
| 433 |
+
|
| 434 |
+
case messageTypes.noteOff:
|
| 435 |
+
e.messageData[0] += coarseTranspose[channel];
|
| 436 |
+
break;
|
| 437 |
+
|
| 438 |
+
case messageTypes.programChange:
|
| 439 |
+
// do we delete it?
|
| 440 |
+
if (channelsToChangeProgram.has(channel))
|
| 441 |
+
{
|
| 442 |
+
// this channel has program change. BEGONE!
|
| 443 |
+
deleteThisEvent();
|
| 444 |
+
continue;
|
| 445 |
+
}
|
| 446 |
+
break;
|
| 447 |
+
|
| 448 |
+
case messageTypes.controllerChange:
|
| 449 |
+
const ccNum = e.messageData[0];
|
| 450 |
+
const changes = desiredControllerChanges.find(c => c.channel === channel && ccNum === c.controllerNumber);
|
| 451 |
+
if (changes !== undefined)
|
| 452 |
+
{
|
| 453 |
+
// this controller is locked, BEGONE CHANGE!
|
| 454 |
+
deleteThisEvent();
|
| 455 |
+
continue;
|
| 456 |
+
}
|
| 457 |
+
// bank maybe?
|
| 458 |
+
if (ccNum === midiControllers.bankSelect || ccNum === midiControllers.lsbForControl0BankSelect)
|
| 459 |
+
{
|
| 460 |
+
if (channelsToChangeProgram.has(channel))
|
| 461 |
+
{
|
| 462 |
+
// BEGONE!
|
| 463 |
+
deleteThisEvent();
|
| 464 |
+
continue;
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
break;
|
| 468 |
+
|
| 469 |
+
case messageTypes.systemExclusive:
|
| 470 |
+
// check for xg on
|
| 471 |
+
if (isXGOn(e))
|
| 472 |
+
{
|
| 473 |
+
SpessaSynthInfo("%cXG system on detected", consoleColors.info);
|
| 474 |
+
system = "xg";
|
| 475 |
+
addedGs = true; // flag as true so gs won't get added
|
| 476 |
+
}
|
| 477 |
+
else
|
| 478 |
+
// check for xg program change
|
| 479 |
+
if (
|
| 480 |
+
e.messageData[0] === 0x43 // yamaha
|
| 481 |
+
&& e.messageData[2] === 0x4C // XG
|
| 482 |
+
&& e.messageData[3] === 0x08 // part parameter
|
| 483 |
+
&& e.messageData[5] === 0x03 // program change
|
| 484 |
+
)
|
| 485 |
+
{
|
| 486 |
+
// do we delete it?
|
| 487 |
+
if (channelsToChangeProgram.has(e.messageData[4] + portOffset))
|
| 488 |
+
{
|
| 489 |
+
// this channel has program change. BEGONE!
|
| 490 |
+
deleteThisEvent();
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
else
|
| 494 |
+
// check for GS on
|
| 495 |
+
if (isGSOn(e))
|
| 496 |
+
{
|
| 497 |
+
// that's a GS on, we're done here
|
| 498 |
+
addedGs = true;
|
| 499 |
+
SpessaSynthInfo(
|
| 500 |
+
"%cGS on detected!",
|
| 501 |
+
consoleColors.recognized
|
| 502 |
+
);
|
| 503 |
+
break;
|
| 504 |
+
}
|
| 505 |
+
else
|
| 506 |
+
// check for GM/2 on
|
| 507 |
+
if (isGMOn(e) || isGM2On(e))
|
| 508 |
+
{
|
| 509 |
+
// that's a GM1 system change, remove it!
|
| 510 |
+
SpessaSynthInfo(
|
| 511 |
+
"%cGM/2 on detected, removing!",
|
| 512 |
+
consoleColors.info
|
| 513 |
+
);
|
| 514 |
+
deleteThisEvent();
|
| 515 |
+
addedGs = false;
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
// check for gs
|
| 520 |
+
if (!addedGs && desiredProgramChanges.length > 0)
|
| 521 |
+
{
|
| 522 |
+
// gs is not on, add it on the first track at index 0 (or 1 if track name is first)
|
| 523 |
+
let index = 0;
|
| 524 |
+
if (midi.tracks[0][0].messageStatusByte === messageTypes.trackName)
|
| 525 |
+
{
|
| 526 |
+
index++;
|
| 527 |
+
}
|
| 528 |
+
midi.tracks[0].splice(index, 0, getGsOn(0));
|
| 529 |
+
SpessaSynthInfo("%cGS on not detected. Adding it.", consoleColors.info);
|
| 530 |
+
}
|
| 531 |
+
this.flush();
|
| 532 |
+
SpessaSynthGroupEnd();
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/**
|
| 536 |
+
* Modifies the sequence according to the locked presets and controllers in the given snapshot
|
| 537 |
+
* @this {BasicMIDI}
|
| 538 |
+
* @param snapshot {SynthesizerSnapshot}
|
| 539 |
+
*/
|
| 540 |
+
export function applySnapshotToMIDI(snapshot)
|
| 541 |
+
{
|
| 542 |
+
/**
|
| 543 |
+
* @type {{
|
| 544 |
+
* channel: number,
|
| 545 |
+
* keyShift: number
|
| 546 |
+
* }[]}
|
| 547 |
+
*/
|
| 548 |
+
const channelsToTranspose = [];
|
| 549 |
+
/**
|
| 550 |
+
* @type {number[]}
|
| 551 |
+
*/
|
| 552 |
+
const channelsToClear = [];
|
| 553 |
+
/**
|
| 554 |
+
* @type {{
|
| 555 |
+
* channel: number,
|
| 556 |
+
* program: number,
|
| 557 |
+
* bank: number,
|
| 558 |
+
* isDrum: boolean
|
| 559 |
+
* }[]}
|
| 560 |
+
*/
|
| 561 |
+
const programChanges = [];
|
| 562 |
+
/**
|
| 563 |
+
*
|
| 564 |
+
* @type {{
|
| 565 |
+
* channel: number,
|
| 566 |
+
* controllerNumber: number,
|
| 567 |
+
* controllerValue: number
|
| 568 |
+
* }[]}
|
| 569 |
+
*/
|
| 570 |
+
const controllerChanges = [];
|
| 571 |
+
snapshot.channelSnapshots.forEach((channel, channelNumber) =>
|
| 572 |
+
{
|
| 573 |
+
if (channel.isMuted)
|
| 574 |
+
{
|
| 575 |
+
channelsToClear.push(channelNumber);
|
| 576 |
+
return;
|
| 577 |
+
}
|
| 578 |
+
const transposeFloat = channel.channelTransposeKeyShift + channel.customControllers[customControllers.channelTransposeFine] / 100;
|
| 579 |
+
if (transposeFloat !== 0)
|
| 580 |
+
{
|
| 581 |
+
channelsToTranspose.push({
|
| 582 |
+
channel: channelNumber,
|
| 583 |
+
keyShift: transposeFloat
|
| 584 |
+
});
|
| 585 |
+
}
|
| 586 |
+
if (channel.lockPreset)
|
| 587 |
+
{
|
| 588 |
+
programChanges.push({
|
| 589 |
+
channel: channelNumber,
|
| 590 |
+
program: channel.program,
|
| 591 |
+
bank: channel.bank,
|
| 592 |
+
isDrum: channel.drumChannel
|
| 593 |
+
});
|
| 594 |
+
}
|
| 595 |
+
// check for locked controllers and change them appropriately
|
| 596 |
+
channel.lockedControllers.forEach((l, ccNumber) =>
|
| 597 |
+
{
|
| 598 |
+
if (!l || ccNumber > 127 || ccNumber === midiControllers.bankSelect)
|
| 599 |
+
{
|
| 600 |
+
return;
|
| 601 |
+
}
|
| 602 |
+
const targetValue = channel.midiControllers[ccNumber] >> 7; // channel controllers are stored as 14 bit values
|
| 603 |
+
controllerChanges.push({
|
| 604 |
+
channel: channelNumber,
|
| 605 |
+
controllerNumber: ccNumber,
|
| 606 |
+
controllerValue: targetValue
|
| 607 |
+
});
|
| 608 |
+
});
|
| 609 |
+
});
|
| 610 |
+
this.modifyMIDI(programChanges, controllerChanges, channelsToClear, channelsToTranspose);
|
| 611 |
+
}
|
spessasynth_lib/midi_parser/midi_loader.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { dataBytesAmount, getChannel, MIDIMessage } from "./midi_message.js";
|
| 2 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 3 |
+
import { consoleColors } from "../utils/other.js";
|
| 4 |
+
import { SpessaSynthGroupCollapsed, SpessaSynthGroupEnd, SpessaSynthInfo, SpessaSynthWarn } from "../utils/loggin.js";
|
| 5 |
+
import { readRIFFChunk } from "../soundfont/basic_soundfont/riff_chunk.js";
|
| 6 |
+
import { readVariableLengthQuantity } from "../utils/byte_functions/variable_length_quantity.js";
|
| 7 |
+
import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
|
| 8 |
+
import { readBytesAsString } from "../utils/byte_functions/string.js";
|
| 9 |
+
import { readLittleEndian } from "../utils/byte_functions/little_endian.js";
|
| 10 |
+
import { RMIDINFOChunks } from "./rmidi_writer.js";
|
| 11 |
+
import { BasicMIDI } from "./basic_midi.js";
|
| 12 |
+
import { loadXMF } from "./xmf_loader.js";
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* midi_loader.js
|
| 16 |
+
* purpose:
|
| 17 |
+
* parses a midi file for the seqyencer,
|
| 18 |
+
* including things like marker or CC 2/4 loop detection, copyright detection, etc.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* The MIDI class is a MIDI file parser that reads a MIDI file and extracts all the necessary information from it.
|
| 23 |
+
* Supported formats are .mid and .rmi files.
|
| 24 |
+
*/
|
| 25 |
+
class MIDI extends BasicMIDI
|
| 26 |
+
{
|
| 27 |
+
/**
|
| 28 |
+
* Parses a given midi file
|
| 29 |
+
* @param arrayBuffer {ArrayBuffer}
|
| 30 |
+
* @param fileName {string} optional, replaces the decoded title if empty
|
| 31 |
+
*/
|
| 32 |
+
constructor(arrayBuffer, fileName = "")
|
| 33 |
+
{
|
| 34 |
+
super();
|
| 35 |
+
SpessaSynthGroupCollapsed(`%cParsing MIDI File...`, consoleColors.info);
|
| 36 |
+
this.fileName = fileName;
|
| 37 |
+
const binaryData = new IndexedByteArray(arrayBuffer);
|
| 38 |
+
let fileByteArray;
|
| 39 |
+
|
| 40 |
+
// check for rmid
|
| 41 |
+
const initialString = readBytesAsString(binaryData, 4);
|
| 42 |
+
binaryData.currentIndex -= 4;
|
| 43 |
+
if (initialString === "RIFF")
|
| 44 |
+
{
|
| 45 |
+
// possibly an RMID file (https://github.com/spessasus/sf2-rmidi-specification#readme)
|
| 46 |
+
// skip size
|
| 47 |
+
binaryData.currentIndex += 8;
|
| 48 |
+
const rmid = readBytesAsString(binaryData, 4, undefined, false);
|
| 49 |
+
if (rmid !== "RMID")
|
| 50 |
+
{
|
| 51 |
+
SpessaSynthGroupEnd();
|
| 52 |
+
throw new SyntaxError(`Invalid RMIDI Header! Expected "RMID", got "${rmid}"`);
|
| 53 |
+
}
|
| 54 |
+
const riff = readRIFFChunk(binaryData);
|
| 55 |
+
if (riff.header !== "data")
|
| 56 |
+
{
|
| 57 |
+
SpessaSynthGroupEnd();
|
| 58 |
+
throw new SyntaxError(`Invalid RMIDI Chunk header! Expected "data", got "${rmid}"`);
|
| 59 |
+
}
|
| 60 |
+
// this is a rmid, load the midi into an array for parsing
|
| 61 |
+
fileByteArray = riff.chunkData;
|
| 62 |
+
|
| 63 |
+
// keep loading chunks until we get the "SFBK" header
|
| 64 |
+
while (binaryData.currentIndex <= binaryData.length)
|
| 65 |
+
{
|
| 66 |
+
const startIndex = binaryData.currentIndex;
|
| 67 |
+
const currentChunk = readRIFFChunk(binaryData, true);
|
| 68 |
+
if (currentChunk.header === "RIFF")
|
| 69 |
+
{
|
| 70 |
+
const type = readBytesAsString(currentChunk.chunkData, 4).toLowerCase();
|
| 71 |
+
if (type === "sfbk" || type === "sfpk" || type === "dls ")
|
| 72 |
+
{
|
| 73 |
+
SpessaSynthInfo("%cFound embedded soundfont!", consoleColors.recognized);
|
| 74 |
+
this.embeddedSoundFont = binaryData.slice(startIndex, startIndex + currentChunk.size).buffer;
|
| 75 |
+
}
|
| 76 |
+
else
|
| 77 |
+
{
|
| 78 |
+
SpessaSynthWarn(`Unknown RIFF chunk: "${type}"`);
|
| 79 |
+
}
|
| 80 |
+
if (type === "dls ")
|
| 81 |
+
{
|
| 82 |
+
// Assume bank offset of 0 by default. If we find any bank selects, then the offset is 1.
|
| 83 |
+
this.isDLSRMIDI = true;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
else if (currentChunk.header === "LIST")
|
| 87 |
+
{
|
| 88 |
+
const type = readBytesAsString(currentChunk.chunkData, 4);
|
| 89 |
+
if (type === "INFO")
|
| 90 |
+
{
|
| 91 |
+
SpessaSynthInfo("%cFound RMIDI INFO chunk!", consoleColors.recognized);
|
| 92 |
+
this.RMIDInfo = {};
|
| 93 |
+
while (currentChunk.chunkData.currentIndex <= currentChunk.size)
|
| 94 |
+
{
|
| 95 |
+
const infoChunk = readRIFFChunk(currentChunk.chunkData, true);
|
| 96 |
+
this.RMIDInfo[infoChunk.header] = infoChunk.chunkData;
|
| 97 |
+
}
|
| 98 |
+
if (this.RMIDInfo["ICOP"])
|
| 99 |
+
{
|
| 100 |
+
// special case, overwrites the copyright components array
|
| 101 |
+
this.copyright = readBytesAsString(
|
| 102 |
+
this.RMIDInfo["ICOP"],
|
| 103 |
+
this.RMIDInfo["ICOP"].length,
|
| 104 |
+
undefined,
|
| 105 |
+
false
|
| 106 |
+
).replaceAll("\n", " ");
|
| 107 |
+
}
|
| 108 |
+
if (this.RMIDInfo["INAM"])
|
| 109 |
+
{
|
| 110 |
+
this.rawMidiName = this.RMIDInfo[RMIDINFOChunks.name];
|
| 111 |
+
// noinspection JSCheckFunctionSignatures
|
| 112 |
+
this.midiName = readBytesAsString(
|
| 113 |
+
this.rawMidiName,
|
| 114 |
+
this.rawMidiName.length,
|
| 115 |
+
undefined,
|
| 116 |
+
false
|
| 117 |
+
).replaceAll("\n", " ");
|
| 118 |
+
}
|
| 119 |
+
// these can be used interchangeably
|
| 120 |
+
if (this.RMIDInfo["IALB"] && !this.RMIDInfo["IPRD"])
|
| 121 |
+
{
|
| 122 |
+
this.RMIDInfo["IPRD"] = this.RMIDInfo["IALB"];
|
| 123 |
+
}
|
| 124 |
+
if (this.RMIDInfo["IPRD"] && !this.RMIDInfo["IALB"])
|
| 125 |
+
{
|
| 126 |
+
this.RMIDInfo["IALB"] = this.RMIDInfo["IPRD"];
|
| 127 |
+
}
|
| 128 |
+
this.bankOffset = 1; // defaults to 1
|
| 129 |
+
if (this.RMIDInfo[RMIDINFOChunks.bankOffset])
|
| 130 |
+
{
|
| 131 |
+
this.bankOffset = readLittleEndian(this.RMIDInfo[RMIDINFOChunks.bankOffset], 2);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (this.isDLSRMIDI)
|
| 138 |
+
{
|
| 139 |
+
// Assume bank offset of 0 by default. If we find any bank selects, then the offset is 1.
|
| 140 |
+
this.bankOffset = 0;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// if no embedded bank, assume 0
|
| 144 |
+
if (this.embeddedSoundFont === undefined)
|
| 145 |
+
{
|
| 146 |
+
this.bankOffset = 0;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
else if (initialString === "XMF_")
|
| 150 |
+
{
|
| 151 |
+
// XMF file
|
| 152 |
+
fileByteArray = loadXMF(this, binaryData);
|
| 153 |
+
}
|
| 154 |
+
else
|
| 155 |
+
{
|
| 156 |
+
fileByteArray = binaryData;
|
| 157 |
+
}
|
| 158 |
+
const headerChunk = this._readMIDIChunk(fileByteArray);
|
| 159 |
+
if (headerChunk.type !== "MThd")
|
| 160 |
+
{
|
| 161 |
+
SpessaSynthGroupEnd();
|
| 162 |
+
throw new SyntaxError(`Invalid MIDI Header! Expected "MThd", got "${headerChunk.type}"`);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (headerChunk.size !== 6)
|
| 166 |
+
{
|
| 167 |
+
SpessaSynthGroupEnd();
|
| 168 |
+
throw new RangeError(`Invalid MIDI header chunk size! Expected 6, got ${headerChunk.size}`);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// format
|
| 172 |
+
this.format = readBytesAsUintBigEndian(headerChunk.data, 2);
|
| 173 |
+
// tracks count
|
| 174 |
+
this.tracksAmount = readBytesAsUintBigEndian(headerChunk.data, 2);
|
| 175 |
+
// time division
|
| 176 |
+
this.timeDivision = readBytesAsUintBigEndian(headerChunk.data, 2);
|
| 177 |
+
// read all the tracks
|
| 178 |
+
for (let i = 0; i < this.tracksAmount; i++)
|
| 179 |
+
{
|
| 180 |
+
/**
|
| 181 |
+
* @type {MIDIMessage[]}
|
| 182 |
+
*/
|
| 183 |
+
const track = [];
|
| 184 |
+
const trackChunk = this._readMIDIChunk(fileByteArray);
|
| 185 |
+
|
| 186 |
+
if (trackChunk.type !== "MTrk")
|
| 187 |
+
{
|
| 188 |
+
SpessaSynthGroupEnd();
|
| 189 |
+
throw new SyntaxError(`Invalid track header! Expected "MTrk" got "${trackChunk.type}"`);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* MIDI running byte
|
| 195 |
+
* @type {number}
|
| 196 |
+
*/
|
| 197 |
+
let runningByte = undefined;
|
| 198 |
+
|
| 199 |
+
let totalTicks = 0;
|
| 200 |
+
// format 2 plays sequentially
|
| 201 |
+
if (this.format === 2 && i > 0)
|
| 202 |
+
{
|
| 203 |
+
totalTicks += this.tracks[i - 1][this.tracks[i - 1].length - 1].ticks;
|
| 204 |
+
}
|
| 205 |
+
// loop until we reach the end of track
|
| 206 |
+
while (trackChunk.data.currentIndex < trackChunk.size)
|
| 207 |
+
{
|
| 208 |
+
totalTicks += readVariableLengthQuantity(trackChunk.data);
|
| 209 |
+
|
| 210 |
+
// check if the status byte is valid (IE. larger than 127)
|
| 211 |
+
const statusByteCheck = trackChunk.data[trackChunk.data.currentIndex];
|
| 212 |
+
|
| 213 |
+
let statusByte;
|
| 214 |
+
// if we have a running byte and the status byte isn't valid
|
| 215 |
+
if (runningByte !== undefined && statusByteCheck < 0x80)
|
| 216 |
+
{
|
| 217 |
+
statusByte = runningByte;
|
| 218 |
+
}
|
| 219 |
+
else
|
| 220 |
+
{ // noinspection PointlessBooleanExpressionJS
|
| 221 |
+
if (runningByte === undefined && statusByteCheck < 0x80)
|
| 222 |
+
{
|
| 223 |
+
// if we don't have a running byte and the status byte isn't valid, it's an error.
|
| 224 |
+
SpessaSynthGroupEnd();
|
| 225 |
+
throw new SyntaxError(`Unexpected byte with no running byte. (${statusByteCheck})`);
|
| 226 |
+
}
|
| 227 |
+
else
|
| 228 |
+
{
|
| 229 |
+
// if the status byte is valid, use that
|
| 230 |
+
statusByte = trackChunk.data[trackChunk.data.currentIndex++];
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
const statusByteChannel = getChannel(statusByte);
|
| 234 |
+
|
| 235 |
+
let eventDataLength;
|
| 236 |
+
|
| 237 |
+
// determine the message's length;
|
| 238 |
+
switch (statusByteChannel)
|
| 239 |
+
{
|
| 240 |
+
case -1:
|
| 241 |
+
// system common/realtime (no length)
|
| 242 |
+
eventDataLength = 0;
|
| 243 |
+
break;
|
| 244 |
+
|
| 245 |
+
case -2:
|
| 246 |
+
// meta (the next is the actual status byte)
|
| 247 |
+
statusByte = trackChunk.data[trackChunk.data.currentIndex++];
|
| 248 |
+
eventDataLength = readVariableLengthQuantity(trackChunk.data);
|
| 249 |
+
break;
|
| 250 |
+
|
| 251 |
+
case -3:
|
| 252 |
+
// sysex
|
| 253 |
+
eventDataLength = readVariableLengthQuantity(trackChunk.data);
|
| 254 |
+
break;
|
| 255 |
+
|
| 256 |
+
default:
|
| 257 |
+
// voice message
|
| 258 |
+
// gets the midi message length
|
| 259 |
+
eventDataLength = dataBytesAmount[statusByte >> 4];
|
| 260 |
+
// save the status byte
|
| 261 |
+
runningByte = statusByte;
|
| 262 |
+
break;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// put the event data into the array
|
| 266 |
+
const eventData = new IndexedByteArray(eventDataLength);
|
| 267 |
+
eventData.set(trackChunk.data.slice(
|
| 268 |
+
trackChunk.data.currentIndex,
|
| 269 |
+
trackChunk.data.currentIndex + eventDataLength
|
| 270 |
+
), 0);
|
| 271 |
+
const event = new MIDIMessage(totalTicks, statusByte, eventData);
|
| 272 |
+
track.push(event);
|
| 273 |
+
// advance the track chunk
|
| 274 |
+
trackChunk.data.currentIndex += eventDataLength;
|
| 275 |
+
}
|
| 276 |
+
this.tracks.push(track);
|
| 277 |
+
|
| 278 |
+
SpessaSynthInfo(
|
| 279 |
+
`%cParsed %c${this.tracks.length}%c / %c${this.tracksAmount}`,
|
| 280 |
+
consoleColors.info,
|
| 281 |
+
consoleColors.value,
|
| 282 |
+
consoleColors.info,
|
| 283 |
+
consoleColors.value
|
| 284 |
+
);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
SpessaSynthInfo(
|
| 288 |
+
`%cAll tracks parsed correctly!`,
|
| 289 |
+
consoleColors.recognized
|
| 290 |
+
);
|
| 291 |
+
// parse the events
|
| 292 |
+
this._parseInternal();
|
| 293 |
+
SpessaSynthGroupEnd();
|
| 294 |
+
SpessaSynthInfo(
|
| 295 |
+
`%cMIDI file parsed. Total tick time: %c${this.lastVoiceEventTick}%c, total seconds time: %c${this.duration}`,
|
| 296 |
+
consoleColors.info,
|
| 297 |
+
consoleColors.recognized,
|
| 298 |
+
consoleColors.info,
|
| 299 |
+
consoleColors.recognized
|
| 300 |
+
);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/**
|
| 304 |
+
* @param fileByteArray {IndexedByteArray}
|
| 305 |
+
* @returns {{type: string, size: number, data: IndexedByteArray}}
|
| 306 |
+
* @private
|
| 307 |
+
*/
|
| 308 |
+
_readMIDIChunk(fileByteArray)
|
| 309 |
+
{
|
| 310 |
+
const chunk = {};
|
| 311 |
+
// type
|
| 312 |
+
chunk.type = readBytesAsString(fileByteArray, 4);
|
| 313 |
+
// size
|
| 314 |
+
chunk.size = readBytesAsUintBigEndian(fileByteArray, 4);
|
| 315 |
+
// data
|
| 316 |
+
chunk.data = new IndexedByteArray(chunk.size);
|
| 317 |
+
const dataSlice = fileByteArray.slice(fileByteArray.currentIndex, fileByteArray.currentIndex + chunk.size);
|
| 318 |
+
chunk.data.set(dataSlice, 0);
|
| 319 |
+
fileByteArray.currentIndex += chunk.size;
|
| 320 |
+
return chunk;
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
export { MIDI };
|
spessasynth_lib/midi_parser/midi_message.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* midi_message.js
|
| 5 |
+
* purpose: contains enums for midi events and controllers and functions to parse them
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
export class MIDIMessage
|
| 9 |
+
{
|
| 10 |
+
/**
|
| 11 |
+
* Absolute number of MIDI ticks from the start of the track.
|
| 12 |
+
* @type {number}
|
| 13 |
+
*/
|
| 14 |
+
ticks;
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* The MIDI message status byte. Note that for meta events, it is the second byte. (not 0xFF)
|
| 18 |
+
* @type {number}
|
| 19 |
+
*/
|
| 20 |
+
messageStatusByte;
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Message's binary data
|
| 24 |
+
* @type {IndexedByteArray}
|
| 25 |
+
*/
|
| 26 |
+
messageData;
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* @param ticks {number}
|
| 30 |
+
* @param byte {number} the message status byte
|
| 31 |
+
* @param data {IndexedByteArray}
|
| 32 |
+
*/
|
| 33 |
+
constructor(ticks, byte, data)
|
| 34 |
+
{
|
| 35 |
+
this.ticks = ticks;
|
| 36 |
+
this.messageStatusByte = byte;
|
| 37 |
+
this.messageData = data;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Gets the status byte's channel
|
| 43 |
+
* @param statusByte
|
| 44 |
+
* @returns {number} channel is -1 for system messages -2 for meta and -3 for sysex
|
| 45 |
+
*/
|
| 46 |
+
export function getChannel(statusByte)
|
| 47 |
+
{
|
| 48 |
+
const eventType = statusByte & 0xF0;
|
| 49 |
+
const channel = statusByte & 0x0F;
|
| 50 |
+
|
| 51 |
+
let resultChannel = channel;
|
| 52 |
+
|
| 53 |
+
switch (eventType)
|
| 54 |
+
{
|
| 55 |
+
// midi (and meta and sysex headers)
|
| 56 |
+
case 0x80:
|
| 57 |
+
case 0x90:
|
| 58 |
+
case 0xA0:
|
| 59 |
+
case 0xB0:
|
| 60 |
+
case 0xC0:
|
| 61 |
+
case 0xD0:
|
| 62 |
+
case 0xE0:
|
| 63 |
+
break;
|
| 64 |
+
|
| 65 |
+
case 0xF0:
|
| 66 |
+
switch (channel)
|
| 67 |
+
{
|
| 68 |
+
case 0x0:
|
| 69 |
+
resultChannel = -3;
|
| 70 |
+
break;
|
| 71 |
+
|
| 72 |
+
case 0x1:
|
| 73 |
+
case 0x2:
|
| 74 |
+
case 0x3:
|
| 75 |
+
case 0x4:
|
| 76 |
+
case 0x5:
|
| 77 |
+
case 0x6:
|
| 78 |
+
case 0x7:
|
| 79 |
+
case 0x8:
|
| 80 |
+
case 0x9:
|
| 81 |
+
case 0xA:
|
| 82 |
+
case 0xB:
|
| 83 |
+
case 0xC:
|
| 84 |
+
case 0xD:
|
| 85 |
+
case 0xE:
|
| 86 |
+
resultChannel = -1;
|
| 87 |
+
break;
|
| 88 |
+
|
| 89 |
+
case 0xF:
|
| 90 |
+
resultChannel = -2;
|
| 91 |
+
break;
|
| 92 |
+
}
|
| 93 |
+
break;
|
| 94 |
+
|
| 95 |
+
default:
|
| 96 |
+
resultChannel = -1;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return resultChannel;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// all the midi statuses dictionary
|
| 103 |
+
export const messageTypes = {
|
| 104 |
+
noteOff: 0x80,
|
| 105 |
+
noteOn: 0x90,
|
| 106 |
+
polyPressure: 0xA0,
|
| 107 |
+
controllerChange: 0xB0,
|
| 108 |
+
programChange: 0xC0,
|
| 109 |
+
channelPressure: 0xD0,
|
| 110 |
+
pitchBend: 0xE0,
|
| 111 |
+
systemExclusive: 0xF0,
|
| 112 |
+
timecode: 0xF1,
|
| 113 |
+
songPosition: 0xF2,
|
| 114 |
+
songSelect: 0xF3,
|
| 115 |
+
tuneRequest: 0xF6,
|
| 116 |
+
clock: 0xF8,
|
| 117 |
+
start: 0xFA,
|
| 118 |
+
continue: 0xFB,
|
| 119 |
+
stop: 0xFC,
|
| 120 |
+
activeSensing: 0xFE,
|
| 121 |
+
reset: 0xFF,
|
| 122 |
+
sequenceNumber: 0x00,
|
| 123 |
+
text: 0x01,
|
| 124 |
+
copyright: 0x02,
|
| 125 |
+
trackName: 0x03,
|
| 126 |
+
instrumentName: 0x04,
|
| 127 |
+
lyric: 0x05,
|
| 128 |
+
marker: 0x06,
|
| 129 |
+
cuePoint: 0x07,
|
| 130 |
+
programName: 0x08,
|
| 131 |
+
midiChannelPrefix: 0x20,
|
| 132 |
+
midiPort: 0x21,
|
| 133 |
+
endOfTrack: 0x2F,
|
| 134 |
+
setTempo: 0x51,
|
| 135 |
+
smpteOffset: 0x54,
|
| 136 |
+
timeSignature: 0x58,
|
| 137 |
+
keySignature: 0x59,
|
| 138 |
+
sequenceSpecific: 0x7F
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Gets the event's status and channel from the status byte
|
| 144 |
+
* @param statusByte {number} the status byte
|
| 145 |
+
* @returns {{channel: number, status: number}} channel will be -1 for sysex and meta
|
| 146 |
+
*/
|
| 147 |
+
export function getEvent(statusByte)
|
| 148 |
+
{
|
| 149 |
+
const status = statusByte & 0xF0;
|
| 150 |
+
const channel = statusByte & 0x0F;
|
| 151 |
+
|
| 152 |
+
let eventChannel = -1;
|
| 153 |
+
let eventStatus = statusByte;
|
| 154 |
+
|
| 155 |
+
if (status >= 0x80 && status <= 0xE0)
|
| 156 |
+
{
|
| 157 |
+
eventChannel = channel;
|
| 158 |
+
eventStatus = status;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
status: eventStatus,
|
| 163 |
+
channel: eventChannel
|
| 164 |
+
};
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* @enum {number}
|
| 170 |
+
*/
|
| 171 |
+
export const midiControllers = {
|
| 172 |
+
bankSelect: 0,
|
| 173 |
+
modulationWheel: 1,
|
| 174 |
+
breathController: 2,
|
| 175 |
+
footController: 4,
|
| 176 |
+
portamentoTime: 5,
|
| 177 |
+
dataEntryMsb: 6,
|
| 178 |
+
mainVolume: 7,
|
| 179 |
+
balance: 8,
|
| 180 |
+
pan: 10,
|
| 181 |
+
expressionController: 11,
|
| 182 |
+
effectControl1: 12,
|
| 183 |
+
effectControl2: 13,
|
| 184 |
+
generalPurposeController1: 16,
|
| 185 |
+
generalPurposeController2: 17,
|
| 186 |
+
generalPurposeController3: 18,
|
| 187 |
+
generalPurposeController4: 19,
|
| 188 |
+
lsbForControl0BankSelect: 32,
|
| 189 |
+
lsbForControl1ModulationWheel: 33,
|
| 190 |
+
lsbForControl2BreathController: 34,
|
| 191 |
+
lsbForControl4FootController: 36,
|
| 192 |
+
lsbForControl5PortamentoTime: 37,
|
| 193 |
+
lsbForControl6DataEntry: 38,
|
| 194 |
+
lsbForControl7MainVolume: 39,
|
| 195 |
+
lsbForControl8Balance: 40,
|
| 196 |
+
lsbForControl10Pan: 42,
|
| 197 |
+
lsbForControl11ExpressionController: 43,
|
| 198 |
+
lsbForControl12EffectControl1: 44,
|
| 199 |
+
lsbForControl13EffectControl2: 45,
|
| 200 |
+
sustainPedal: 64,
|
| 201 |
+
portamentoOnOff: 65,
|
| 202 |
+
sostenutoPedal: 66,
|
| 203 |
+
softPedal: 67,
|
| 204 |
+
legatoFootswitch: 68,
|
| 205 |
+
hold2Pedal: 69,
|
| 206 |
+
soundVariation: 70,
|
| 207 |
+
filterResonance: 71,
|
| 208 |
+
releaseTime: 72,
|
| 209 |
+
attackTime: 73,
|
| 210 |
+
brightness: 74,
|
| 211 |
+
decayTime: 75,
|
| 212 |
+
vibratoRate: 76,
|
| 213 |
+
vibratoDepth: 77,
|
| 214 |
+
vibratoDelay: 78,
|
| 215 |
+
soundController10: 79,
|
| 216 |
+
generalPurposeController5: 80,
|
| 217 |
+
generalPurposeController6: 81,
|
| 218 |
+
generalPurposeController7: 82,
|
| 219 |
+
generalPurposeController8: 83,
|
| 220 |
+
portamentoControl: 84,
|
| 221 |
+
reverbDepth: 91,
|
| 222 |
+
tremoloDepth: 92,
|
| 223 |
+
chorusDepth: 93,
|
| 224 |
+
detuneDepth: 94,
|
| 225 |
+
phaserDepth: 95,
|
| 226 |
+
dataIncrement: 96,
|
| 227 |
+
dataDecrement: 97,
|
| 228 |
+
NRPNLsb: 98,
|
| 229 |
+
NRPNMsb: 99,
|
| 230 |
+
RPNLsb: 100,
|
| 231 |
+
RPNMsb: 101,
|
| 232 |
+
allSoundOff: 120,
|
| 233 |
+
resetAllControllers: 121,
|
| 234 |
+
localControlOnOff: 122,
|
| 235 |
+
allNotesOff: 123,
|
| 236 |
+
omniModeOff: 124,
|
| 237 |
+
omniModeOn: 125,
|
| 238 |
+
monoModeOn: 126,
|
| 239 |
+
polyModeOn: 127
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
/**
|
| 244 |
+
* @type {{"11": number, "12": number, "13": number, "14": number, "8": number, "9": number, "10": number}}
|
| 245 |
+
*/
|
| 246 |
+
export const dataBytesAmount = {
|
| 247 |
+
0x8: 2, // note off
|
| 248 |
+
0x9: 2, // note on
|
| 249 |
+
0xA: 2, // note at
|
| 250 |
+
0xB: 2, // cc change
|
| 251 |
+
0xC: 1, // pg change
|
| 252 |
+
0xD: 1, // channel after touch
|
| 253 |
+
0xE: 2 // pitch wheel
|
| 254 |
+
};
|
spessasynth_lib/midi_parser/midi_sequence.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* This is the base type for MIDI files. It contains all the "metadata" and information.
|
| 3 |
+
* It extends to:
|
| 4 |
+
* - BasicMIDI, which contains the actual track data of the MIDI file. Essentially the MIDI file itself.
|
| 5 |
+
* - MIDIData, which contains all properties that MIDI does, except for tracks and the embedded soundfont.
|
| 6 |
+
* MIDIData is the "shell" of the file which is available on the main thread at all times, containing the metadata.
|
| 7 |
+
*/
|
| 8 |
+
class MIDISequenceData
|
| 9 |
+
{
|
| 10 |
+
/**
|
| 11 |
+
* The time division of the sequence, representing the number of ticks per beat.
|
| 12 |
+
* @type {number}
|
| 13 |
+
*/
|
| 14 |
+
timeDivision = 0;
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* The duration of the sequence, in seconds.
|
| 18 |
+
* @type {number}
|
| 19 |
+
*/
|
| 20 |
+
duration = 0;
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* The tempo changes in the sequence, ordered from the last change to the first.
|
| 24 |
+
* Each change is represented by an object with a tick position and a tempo value in beats per minute.
|
| 25 |
+
* @type {{ticks: number, tempo: number}[]}
|
| 26 |
+
*/
|
| 27 |
+
tempoChanges = [{ ticks: 0, tempo: 120 }];
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* A string containing the copyright information for the MIDI sequence if detected.
|
| 31 |
+
* @type {string}
|
| 32 |
+
*/
|
| 33 |
+
copyright = "";
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* The number of tracks in the MIDI sequence.
|
| 37 |
+
* @type {number}
|
| 38 |
+
*/
|
| 39 |
+
tracksAmount = 0;
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* The track names in the MIDI file, an empty string if not set.
|
| 43 |
+
* @type {string[]}
|
| 44 |
+
*/
|
| 45 |
+
trackNames = [];
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* An array containing the lyrics of the sequence, stored as binary chunks (Uint8Array).
|
| 49 |
+
* @type {Uint8Array[]}
|
| 50 |
+
*/
|
| 51 |
+
lyrics = [];
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* An array of tick positions where lyrics events occur in the sequence.
|
| 55 |
+
* @type {number[]}
|
| 56 |
+
*/
|
| 57 |
+
lyricsTicks = [];
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* The tick position of the first note-on event in the MIDI sequence.
|
| 61 |
+
* @type {number}
|
| 62 |
+
*/
|
| 63 |
+
firstNoteOn = 0;
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* The MIDI key range used in the sequence, represented by a minimum and maximum note value.
|
| 67 |
+
* @type {{min: number, max: number}}
|
| 68 |
+
*/
|
| 69 |
+
keyRange = { min: 0, max: 127 };
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* The tick position of the last voice event (such as note-on, note-off, or control change) in the sequence.
|
| 73 |
+
* @type {number}
|
| 74 |
+
*/
|
| 75 |
+
lastVoiceEventTick = 0;
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* An array of MIDI port numbers used by each track in the sequence.
|
| 79 |
+
* @type {number[]}
|
| 80 |
+
*/
|
| 81 |
+
midiPorts = [0];
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* An array of channel offsets for each MIDI port, using the SpessaSynth method.
|
| 85 |
+
* @type {number[]}
|
| 86 |
+
*/
|
| 87 |
+
midiPortChannelOffsets = [0];
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* A list of sets, where each set contains the MIDI channels used by each track in the sequence.
|
| 91 |
+
* @type {Set<number>[]}
|
| 92 |
+
*/
|
| 93 |
+
usedChannelsOnTrack = [];
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* The loop points (in ticks) of the sequence, including both start and end points.
|
| 97 |
+
* @type {{start: number, end: number}}
|
| 98 |
+
*/
|
| 99 |
+
loop = { start: 0, end: 0 };
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* The name of the MIDI sequence.
|
| 103 |
+
* @type {string}
|
| 104 |
+
*/
|
| 105 |
+
midiName = "";
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* A boolean indicating if the sequence's name is the same as the file name.
|
| 109 |
+
* @type {boolean}
|
| 110 |
+
*/
|
| 111 |
+
midiNameUsesFileName = false;
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* The file name of the MIDI sequence, if provided during parsing.
|
| 115 |
+
* @type {string}
|
| 116 |
+
*/
|
| 117 |
+
fileName = "";
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* The raw, encoded MIDI name, represented as a Uint8Array.
|
| 121 |
+
* Useful when the MIDI file uses a different code page.
|
| 122 |
+
* @type {Uint8Array}
|
| 123 |
+
*/
|
| 124 |
+
rawMidiName;
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* The format of the MIDI file, which can be 0, 1, or 2, indicating the type of the MIDI file.
|
| 128 |
+
* @type {number}
|
| 129 |
+
*/
|
| 130 |
+
format = 0;
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* The RMID (Resource-Interchangeable MIDI) info data, if the file is RMID formatted.
|
| 134 |
+
* Otherwise, this field is undefined.
|
| 135 |
+
* Chunk type (e.g. "INAM"): Chunk data as a binary array.
|
| 136 |
+
* @type {Object<string, IndexedByteArray>}
|
| 137 |
+
*/
|
| 138 |
+
RMIDInfo = {};
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* The bank offset used for RMID files.
|
| 142 |
+
* @type {number}
|
| 143 |
+
*/
|
| 144 |
+
bankOffset = 0;
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* If the MIDI file is a Soft Karaoke file (.kar), this flag is set to true.
|
| 148 |
+
* https://www.mixagesoftware.com/en/midikit/help/HTML/karaoke_formats.html
|
| 149 |
+
* @type {boolean}
|
| 150 |
+
*/
|
| 151 |
+
isKaraokeFile = false;
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Indicates if this file is a Multi-Port MIDI file.
|
| 155 |
+
* @type {boolean}
|
| 156 |
+
*/
|
| 157 |
+
isMultiPort = false;
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Converts ticks to time in seconds
|
| 161 |
+
* @param ticks {number} time in MIDI ticks
|
| 162 |
+
* @returns {number} time in seconds
|
| 163 |
+
*/
|
| 164 |
+
MIDIticksToSeconds(ticks)
|
| 165 |
+
{
|
| 166 |
+
let totalSeconds = 0;
|
| 167 |
+
|
| 168 |
+
while (ticks > 0)
|
| 169 |
+
{
|
| 170 |
+
// tempo changes are reversed, so the first element is the last tempo change
|
| 171 |
+
// and the last element is the first tempo change
|
| 172 |
+
// (always at tick 0 and tempo 120)
|
| 173 |
+
// find the last tempo change that has occurred
|
| 174 |
+
let tempo = this.tempoChanges.find(v => v.ticks < ticks);
|
| 175 |
+
|
| 176 |
+
// calculate the difference and tempo time
|
| 177 |
+
let timeSinceLastTempo = ticks - tempo.ticks;
|
| 178 |
+
totalSeconds += (timeSinceLastTempo * 60) / (tempo.tempo * this.timeDivision);
|
| 179 |
+
ticks -= timeSinceLastTempo;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return totalSeconds;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* INTERNAL USE ONLY!
|
| 187 |
+
* DO NOT USE IN SPESSASYNTH_LIB
|
| 188 |
+
* @param sequence {MIDISequenceData}
|
| 189 |
+
* @protected
|
| 190 |
+
*/
|
| 191 |
+
_copyFromSequence(sequence)
|
| 192 |
+
{
|
| 193 |
+
// properties can be assigned
|
| 194 |
+
this.midiName = sequence.midiName;
|
| 195 |
+
this.midiNameUsesFileName = sequence.midiNameUsesFileName;
|
| 196 |
+
this.fileName = sequence.fileName;
|
| 197 |
+
this.timeDivision = sequence.timeDivision;
|
| 198 |
+
this.duration = sequence.duration;
|
| 199 |
+
this.copyright = sequence.copyright;
|
| 200 |
+
this.tracksAmount = sequence.tracksAmount;
|
| 201 |
+
this.firstNoteOn = sequence.firstNoteOn;
|
| 202 |
+
this.lastVoiceEventTick = sequence.lastVoiceEventTick;
|
| 203 |
+
this.format = sequence.format;
|
| 204 |
+
this.bankOffset = sequence.bankOffset;
|
| 205 |
+
this.isKaraokeFile = sequence.isKaraokeFile;
|
| 206 |
+
this.isMultiPort = sequence.isMultiPort;
|
| 207 |
+
|
| 208 |
+
// copying arrays
|
| 209 |
+
this.tempoChanges = [...sequence.tempoChanges];
|
| 210 |
+
this.lyrics = sequence.lyrics.map(arr => new Uint8Array(arr));
|
| 211 |
+
this.lyricsTicks = [...sequence.lyricsTicks];
|
| 212 |
+
this.midiPorts = [...sequence.midiPorts];
|
| 213 |
+
this.trackNames = [...sequence.trackNames];
|
| 214 |
+
this.midiPortChannelOffsets = [...sequence.midiPortChannelOffsets];
|
| 215 |
+
this.usedChannelsOnTrack = sequence.usedChannelsOnTrack.map(set => new Set(set));
|
| 216 |
+
this.rawMidiName = sequence.rawMidiName ? new Uint8Array(sequence.rawMidiName) : undefined;
|
| 217 |
+
|
| 218 |
+
// copying objects
|
| 219 |
+
this.loop = { ...sequence.loop };
|
| 220 |
+
this.keyRange = { ...sequence.keyRange };
|
| 221 |
+
this.RMIDInfo = { ...sequence.RMIDInfo };
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
export { MIDISequenceData };
|
spessasynth_lib/midi_parser/midi_writer.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { messageTypes } from "./midi_message.js";
|
| 2 |
+
import { writeVariableLengthQuantity } from "../utils/byte_functions/variable_length_quantity.js";
|
| 3 |
+
import { writeBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Exports the midi as a standard MIDI file
|
| 7 |
+
* @this {BasicMIDI}
|
| 8 |
+
*/
|
| 9 |
+
export function writeMIDI()
|
| 10 |
+
{
|
| 11 |
+
const midi = this;
|
| 12 |
+
if (!midi.tracks)
|
| 13 |
+
{
|
| 14 |
+
throw new Error("MIDI has no tracks!");
|
| 15 |
+
}
|
| 16 |
+
/**
|
| 17 |
+
* @type {Uint8Array[]}
|
| 18 |
+
*/
|
| 19 |
+
const binaryTrackData = [];
|
| 20 |
+
for (const track of midi.tracks)
|
| 21 |
+
{
|
| 22 |
+
const binaryTrack = [];
|
| 23 |
+
let currentTick = 0;
|
| 24 |
+
let runningByte = undefined;
|
| 25 |
+
for (const event of track)
|
| 26 |
+
{
|
| 27 |
+
// Ticks stored in MIDI are absolute, but SMF wants relative. Convert them here.
|
| 28 |
+
const deltaTicks = event.ticks - currentTick;
|
| 29 |
+
/**
|
| 30 |
+
* @type {number[]}
|
| 31 |
+
*/
|
| 32 |
+
let messageData;
|
| 33 |
+
// determine the message
|
| 34 |
+
if (event.messageStatusByte <= messageTypes.sequenceSpecific)
|
| 35 |
+
{
|
| 36 |
+
// this is a meta-message
|
| 37 |
+
// syntax is FF<type><length><data>
|
| 38 |
+
messageData = [0xff, event.messageStatusByte, ...writeVariableLengthQuantity(event.messageData.length), ...event.messageData];
|
| 39 |
+
}
|
| 40 |
+
else if (event.messageStatusByte === messageTypes.systemExclusive)
|
| 41 |
+
{
|
| 42 |
+
// this is a system exclusive message
|
| 43 |
+
// syntax is F0<length><data>
|
| 44 |
+
messageData = [0xf0, ...writeVariableLengthQuantity(event.messageData.length), ...event.messageData];
|
| 45 |
+
}
|
| 46 |
+
else
|
| 47 |
+
{
|
| 48 |
+
// this is a midi message
|
| 49 |
+
messageData = [];
|
| 50 |
+
if (runningByte !== event.messageStatusByte)
|
| 51 |
+
{
|
| 52 |
+
// Running byte was not the byte we want. Add the byte here.
|
| 53 |
+
runningByte = event.messageStatusByte;
|
| 54 |
+
// add the status byte to the midi
|
| 55 |
+
messageData.push(event.messageStatusByte);
|
| 56 |
+
}
|
| 57 |
+
// add the data
|
| 58 |
+
messageData.push(...event.messageData);
|
| 59 |
+
}
|
| 60 |
+
// write VLQ
|
| 61 |
+
binaryTrack.push(...writeVariableLengthQuantity(deltaTicks));
|
| 62 |
+
// write the message
|
| 63 |
+
binaryTrack.push(...messageData);
|
| 64 |
+
currentTick += deltaTicks;
|
| 65 |
+
}
|
| 66 |
+
binaryTrackData.push(new Uint8Array(binaryTrack));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* @param text {string}
|
| 71 |
+
* @param arr {number[]}
|
| 72 |
+
*/
|
| 73 |
+
function writeText(text, arr)
|
| 74 |
+
{
|
| 75 |
+
for (let i = 0; i < text.length; i++)
|
| 76 |
+
{
|
| 77 |
+
arr.push(text.charCodeAt(i));
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// write the file
|
| 82 |
+
const binaryData = [];
|
| 83 |
+
// write header
|
| 84 |
+
writeText("MThd", binaryData); // MThd
|
| 85 |
+
binaryData.push(...writeBytesAsUintBigEndian(6, 4)); // length
|
| 86 |
+
binaryData.push(0, midi.format); // format
|
| 87 |
+
binaryData.push(...writeBytesAsUintBigEndian(midi.tracksAmount, 2)); // num tracks
|
| 88 |
+
binaryData.push(...writeBytesAsUintBigEndian(midi.timeDivision, 2)); // time division
|
| 89 |
+
|
| 90 |
+
// write tracks
|
| 91 |
+
for (const track of binaryTrackData)
|
| 92 |
+
{
|
| 93 |
+
// write track header
|
| 94 |
+
writeText("MTrk", binaryData); // MTrk
|
| 95 |
+
binaryData.push(...writeBytesAsUintBigEndian(track.length, 4)); // length
|
| 96 |
+
binaryData.push(...track); // write data
|
| 97 |
+
}
|
| 98 |
+
return new Uint8Array(binaryData);
|
| 99 |
+
}
|
spessasynth_lib/midi_parser/rmidi_writer.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { combineArrays, IndexedByteArray } from "../utils/indexed_array.js";
|
| 2 |
+
import { writeRIFFOddSize } from "../soundfont/basic_soundfont/riff_chunk.js";
|
| 3 |
+
import { getStringBytes, getStringBytesZero } from "../utils/byte_functions/string.js";
|
| 4 |
+
import { messageTypes, midiControllers, MIDIMessage } from "./midi_message.js";
|
| 5 |
+
import { getGsOn } from "./midi_editor.js";
|
| 6 |
+
import { SpessaSynthGroup, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
|
| 7 |
+
import { consoleColors } from "../utils/other.js";
|
| 8 |
+
import { writeLittleEndian } from "../utils/byte_functions/little_endian.js";
|
| 9 |
+
import { DEFAULT_PERCUSSION } from "../synthetizer/synth_constants.js";
|
| 10 |
+
import { chooseBank, isSystemXG, parseBankSelect } from "../utils/xg_hacks.js";
|
| 11 |
+
import { isGM2On, isGMOn, isGSDrumsOn, isGSOn, isXGOn } from "../utils/sysex_detector.js";
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* @enum {string}
|
| 15 |
+
*/
|
| 16 |
+
export const RMIDINFOChunks = {
|
| 17 |
+
name: "INAM",
|
| 18 |
+
album: "IPRD",
|
| 19 |
+
album2: "IALB",
|
| 20 |
+
artist: "IART",
|
| 21 |
+
genre: "IGNR",
|
| 22 |
+
picture: "IPIC",
|
| 23 |
+
copyright: "ICOP",
|
| 24 |
+
creationDate: "ICRD",
|
| 25 |
+
comment: "ICMT",
|
| 26 |
+
engineer: "IENG",
|
| 27 |
+
software: "ISFT",
|
| 28 |
+
encoding: "IENC",
|
| 29 |
+
midiEncoding: "MENC",
|
| 30 |
+
bankOffset: "DBNK"
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const FORCED_ENCODING = "utf-8";
|
| 34 |
+
const DEFAULT_COPYRIGHT = "Created using SpessaSynth";
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* @typedef {Object} RMIDMetadata
|
| 38 |
+
* @property {string|undefined} name - the name of the file
|
| 39 |
+
* @property {string|undefined} engineer - the engineer who worked on the file
|
| 40 |
+
* @property {string|undefined} artist - the artist
|
| 41 |
+
* @property {string|undefined} album - the album
|
| 42 |
+
* @property {string|undefined} genre - the genre of the song
|
| 43 |
+
* @property {ArrayBuffer|undefined} picture - the image for the file (album cover)
|
| 44 |
+
* @property {string|undefined} comment - the coment of the file
|
| 45 |
+
* @property {string|undefined} creationDate - the creation date of the file
|
| 46 |
+
* @property {string|undefined} copyright - the copyright of the file
|
| 47 |
+
* @property {string|unescape} midiEncoding - the encoding of the inner MIDI file
|
| 48 |
+
*/
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Writes an RMIDI file
|
| 52 |
+
* @this {BasicMIDI}
|
| 53 |
+
* @param soundfontBinary {Uint8Array}
|
| 54 |
+
* @param soundfont {BasicSoundBank}
|
| 55 |
+
* @param bankOffset {number} the bank offset for RMIDI
|
| 56 |
+
* @param encoding {string} the encoding of the RMIDI info chunk
|
| 57 |
+
* @param metadata {RMIDMetadata} the metadata of the file. Optional. If provided, the encoding is forced to utf-8/
|
| 58 |
+
* @param correctBankOffset {boolean}
|
| 59 |
+
* @returns {IndexedByteArray}
|
| 60 |
+
*/
|
| 61 |
+
export function writeRMIDI(
|
| 62 |
+
soundfontBinary,
|
| 63 |
+
soundfont,
|
| 64 |
+
bankOffset = 0,
|
| 65 |
+
encoding = "Shift_JIS",
|
| 66 |
+
metadata = {},
|
| 67 |
+
correctBankOffset = true
|
| 68 |
+
)
|
| 69 |
+
{
|
| 70 |
+
const mid = this;
|
| 71 |
+
SpessaSynthGroup("%cWriting the RMIDI File...", consoleColors.info);
|
| 72 |
+
SpessaSynthInfo(
|
| 73 |
+
`%cConfiguration: Bank offset: %c${bankOffset}%c, encoding: %c${encoding}`,
|
| 74 |
+
consoleColors.info,
|
| 75 |
+
consoleColors.value,
|
| 76 |
+
consoleColors.info,
|
| 77 |
+
consoleColors.value
|
| 78 |
+
);
|
| 79 |
+
SpessaSynthInfo("metadata", metadata);
|
| 80 |
+
SpessaSynthInfo("Initial bank offset", mid.bankOffset);
|
| 81 |
+
if (correctBankOffset)
|
| 82 |
+
{
|
| 83 |
+
// Add the offset to the bank.
|
| 84 |
+
// See https://github.com/spessasus/sf2-rmidi-specification#readme
|
| 85 |
+
// also fix presets that don't exist
|
| 86 |
+
// since midi player6 doesn't seem to default to 0 when non-existent...
|
| 87 |
+
let system = "gm";
|
| 88 |
+
/**
|
| 89 |
+
* The unwanted system messages such as gm/gm2 on
|
| 90 |
+
* @type {{tNum: number, e: MIDIMessage}[]}
|
| 91 |
+
*/
|
| 92 |
+
let unwantedSystems = [];
|
| 93 |
+
/**
|
| 94 |
+
* indexes for tracks
|
| 95 |
+
* @type {number[]}
|
| 96 |
+
*/
|
| 97 |
+
const eventIndexes = Array(mid.tracks.length).fill(0);
|
| 98 |
+
let remainingTracks = mid.tracks.length;
|
| 99 |
+
|
| 100 |
+
function findFirstEventIndex()
|
| 101 |
+
{
|
| 102 |
+
let index = 0;
|
| 103 |
+
let ticks = Infinity;
|
| 104 |
+
mid.tracks.forEach((track, i) =>
|
| 105 |
+
{
|
| 106 |
+
if (eventIndexes[i] >= track.length)
|
| 107 |
+
{
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
if (track[eventIndexes[i]].ticks < ticks)
|
| 111 |
+
{
|
| 112 |
+
index = i;
|
| 113 |
+
ticks = track[eventIndexes[i]].ticks;
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
return index;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// it copies midiPorts everywhere else, but here 0 works so DO NOT CHANGE!
|
| 120 |
+
const ports = Array(mid.tracks.length).fill(0);
|
| 121 |
+
const channelsAmount = 16 + mid.midiPortChannelOffsets.reduce((max, cur) => cur > max ? cur : max);
|
| 122 |
+
/**
|
| 123 |
+
* @type {{
|
| 124 |
+
* program: number,
|
| 125 |
+
* drums: boolean,
|
| 126 |
+
* lastBank: MIDIMessage,
|
| 127 |
+
* lastBankLSB: MIDIMessage,
|
| 128 |
+
* hasBankSelect: boolean
|
| 129 |
+
* }[]}
|
| 130 |
+
*/
|
| 131 |
+
const channelsInfo = [];
|
| 132 |
+
for (let i = 0; i < channelsAmount; i++)
|
| 133 |
+
{
|
| 134 |
+
channelsInfo.push({
|
| 135 |
+
program: 0,
|
| 136 |
+
drums: i % 16 === DEFAULT_PERCUSSION, // drums appear on 9 every 16 channels,
|
| 137 |
+
lastBank: undefined,
|
| 138 |
+
lastBankLSB: undefined,
|
| 139 |
+
hasBankSelect: false
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
while (remainingTracks > 0)
|
| 143 |
+
{
|
| 144 |
+
let trackNum = findFirstEventIndex();
|
| 145 |
+
const track = mid.tracks[trackNum];
|
| 146 |
+
if (eventIndexes[trackNum] >= track.length)
|
| 147 |
+
{
|
| 148 |
+
remainingTracks--;
|
| 149 |
+
continue;
|
| 150 |
+
}
|
| 151 |
+
const e = track[eventIndexes[trackNum]];
|
| 152 |
+
eventIndexes[trackNum]++;
|
| 153 |
+
|
| 154 |
+
let portOffset = mid.midiPortChannelOffsets[ports[trackNum]];
|
| 155 |
+
if (e.messageStatusByte === messageTypes.midiPort)
|
| 156 |
+
{
|
| 157 |
+
ports[trackNum] = e.messageData[0];
|
| 158 |
+
continue;
|
| 159 |
+
}
|
| 160 |
+
const status = e.messageStatusByte & 0xF0;
|
| 161 |
+
if (
|
| 162 |
+
status !== messageTypes.controllerChange &&
|
| 163 |
+
status !== messageTypes.programChange &&
|
| 164 |
+
status !== messageTypes.systemExclusive
|
| 165 |
+
)
|
| 166 |
+
{
|
| 167 |
+
continue;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
if (status === messageTypes.systemExclusive)
|
| 171 |
+
{
|
| 172 |
+
// check for drum sysex
|
| 173 |
+
if (!isGSDrumsOn(e))
|
| 174 |
+
{
|
| 175 |
+
// check for XG
|
| 176 |
+
if (isXGOn(e))
|
| 177 |
+
{
|
| 178 |
+
system = "xg";
|
| 179 |
+
}
|
| 180 |
+
else if (isGSOn(e))
|
| 181 |
+
{
|
| 182 |
+
system = "gs";
|
| 183 |
+
}
|
| 184 |
+
else if (isGMOn(e))
|
| 185 |
+
{
|
| 186 |
+
// we do not want gm1
|
| 187 |
+
system = "gm";
|
| 188 |
+
unwantedSystems.push({
|
| 189 |
+
tNum: trackNum,
|
| 190 |
+
e: e
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
else if (isGM2On(e))
|
| 194 |
+
{
|
| 195 |
+
system = "gm2";
|
| 196 |
+
}
|
| 197 |
+
continue;
|
| 198 |
+
}
|
| 199 |
+
const sysexChannel = [9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15][e.messageData[5] & 0x0F] + portOffset;
|
| 200 |
+
channelsInfo[sysexChannel].drums = !!(e.messageData[7] > 0 && e.messageData[5] >> 4);
|
| 201 |
+
continue;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// program change
|
| 205 |
+
const chNum = (e.messageStatusByte & 0xF) + portOffset;
|
| 206 |
+
/**
|
| 207 |
+
* @type {{program: number, drums: boolean, lastBank: MIDIMessage, lastBankLSB: MIDIMessage, hasBankSelect: boolean}}
|
| 208 |
+
*/
|
| 209 |
+
const channel = channelsInfo[chNum];
|
| 210 |
+
if (status === messageTypes.programChange)
|
| 211 |
+
{
|
| 212 |
+
const isXG = isSystemXG(system);
|
| 213 |
+
// check if the preset for this program exists
|
| 214 |
+
const initialProgram = e.messageData[0];
|
| 215 |
+
if (channel.drums)
|
| 216 |
+
{
|
| 217 |
+
if (soundfont.presets.findIndex(p => p.program === initialProgram && p.isDrumPreset(
|
| 218 |
+
isXG,
|
| 219 |
+
true
|
| 220 |
+
)) === -1)
|
| 221 |
+
{
|
| 222 |
+
// doesn't exist. pick any preset that has bank 128.
|
| 223 |
+
e.messageData[0] = soundfont.presets.find(p => p.isDrumPreset(isXG))?.program || 0;
|
| 224 |
+
SpessaSynthInfo(
|
| 225 |
+
`%cNo drum preset %c${initialProgram}%c. Channel %c${chNum}%c. Changing program to ${e.messageData[0]}.`,
|
| 226 |
+
consoleColors.info,
|
| 227 |
+
consoleColors.unrecognized,
|
| 228 |
+
consoleColors.info,
|
| 229 |
+
consoleColors.recognized,
|
| 230 |
+
consoleColors.info
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
else
|
| 235 |
+
{
|
| 236 |
+
if (soundfont.presets.findIndex(p => p.program === initialProgram && !p.isDrumPreset(isXG)) === -1)
|
| 237 |
+
{
|
| 238 |
+
// doesn't exist. pick any preset that does not have bank 128.
|
| 239 |
+
e.messageData[0] = soundfont.presets.find(p => !p.isDrumPreset(isXG))?.program || 0;
|
| 240 |
+
SpessaSynthInfo(
|
| 241 |
+
`%cNo preset %c${initialProgram}%c. Channel %c${chNum}%c. Changing program to ${e.messageData[0]}.`,
|
| 242 |
+
consoleColors.info,
|
| 243 |
+
consoleColors.unrecognized,
|
| 244 |
+
consoleColors.info,
|
| 245 |
+
consoleColors.recognized,
|
| 246 |
+
consoleColors.info
|
| 247 |
+
);
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
channel.program = e.messageData[0];
|
| 251 |
+
// check if this preset exists for program and bank
|
| 252 |
+
const realBank = Math.max(0, channel.lastBank?.messageData[1] - mid.bankOffset); // make sure to take the previous bank offset into account
|
| 253 |
+
const bankLSB = (channel?.lastBankLSB?.messageData[1] - mid.bankOffset) || 0;
|
| 254 |
+
if (channel.lastBank === undefined)
|
| 255 |
+
{
|
| 256 |
+
continue;
|
| 257 |
+
}
|
| 258 |
+
// adjust bank for XG
|
| 259 |
+
let bank = chooseBank(realBank, bankLSB, channel.drums, isXG);
|
| 260 |
+
if (soundfont.presets.findIndex(p => p.bank === bank && p.program === e.messageData[0]) === -1)
|
| 261 |
+
{
|
| 262 |
+
// no preset with this bank. find this program with any bank
|
| 263 |
+
const targetBank = (soundfont.presets.find(p => p.program === e.messageData[0])?.bank + bankOffset) || bankOffset;
|
| 264 |
+
channel.lastBank.messageData[1] = targetBank;
|
| 265 |
+
if (channel?.lastBankLSB?.messageData)
|
| 266 |
+
{
|
| 267 |
+
channel.lastBankLSB.messageData[1] = targetBank;
|
| 268 |
+
}
|
| 269 |
+
SpessaSynthInfo(
|
| 270 |
+
`%cNo preset %c${bank}:${e.messageData[0]}%c. Channel %c${chNum}%c. Changing bank to ${targetBank}.`,
|
| 271 |
+
consoleColors.info,
|
| 272 |
+
consoleColors.unrecognized,
|
| 273 |
+
consoleColors.info,
|
| 274 |
+
consoleColors.recognized,
|
| 275 |
+
consoleColors.info
|
| 276 |
+
);
|
| 277 |
+
}
|
| 278 |
+
else
|
| 279 |
+
{
|
| 280 |
+
// There is a preset with this bank. Add offset. For drums add the normal offset.
|
| 281 |
+
let drumBank = bank;
|
| 282 |
+
if (isSystemXG(system) && bank === 128)
|
| 283 |
+
{
|
| 284 |
+
bank = 127;
|
| 285 |
+
}
|
| 286 |
+
const newBank = (bank === 128 ? 128 : drumBank) + bankOffset;
|
| 287 |
+
channel.lastBank.messageData[1] = newBank;
|
| 288 |
+
if (channel?.lastBankLSB?.messageData && !channel.drums)
|
| 289 |
+
{
|
| 290 |
+
channel.lastBankLSB.messageData[1] = channel.lastBankLSB.messageData[1] - mid.bankOffset + bankOffset;
|
| 291 |
+
}
|
| 292 |
+
SpessaSynthInfo(
|
| 293 |
+
`%cPreset %c${bank}:${e.messageData[0]}%c exists. Channel %c${chNum}%c. Changing bank to ${newBank}.`,
|
| 294 |
+
consoleColors.info,
|
| 295 |
+
consoleColors.recognized,
|
| 296 |
+
consoleColors.info,
|
| 297 |
+
consoleColors.recognized,
|
| 298 |
+
consoleColors.info
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
continue;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// controller change
|
| 305 |
+
// we only care about bank-selects
|
| 306 |
+
const isLSB = e.messageData[0] === midiControllers.lsbForControl0BankSelect;
|
| 307 |
+
if (e.messageData[0] !== midiControllers.bankSelect && !isLSB)
|
| 308 |
+
{
|
| 309 |
+
continue;
|
| 310 |
+
}
|
| 311 |
+
// bank select
|
| 312 |
+
channel.hasBankSelect = true;
|
| 313 |
+
const bankNumber = e.messageData[1];
|
| 314 |
+
// interpret
|
| 315 |
+
const intepretation = parseBankSelect(
|
| 316 |
+
channel?.lastBank?.messageData[1] || 0,
|
| 317 |
+
bankNumber,
|
| 318 |
+
system,
|
| 319 |
+
isLSB,
|
| 320 |
+
channel.drums,
|
| 321 |
+
chNum
|
| 322 |
+
);
|
| 323 |
+
if (intepretation.drumsStatus === 2)
|
| 324 |
+
{
|
| 325 |
+
channel.drums = true;
|
| 326 |
+
}
|
| 327 |
+
else if (intepretation.drumsStatus === 1)
|
| 328 |
+
{
|
| 329 |
+
channel.drums = false;
|
| 330 |
+
}
|
| 331 |
+
if (isLSB)
|
| 332 |
+
{
|
| 333 |
+
channel.lastBankLSB = e;
|
| 334 |
+
}
|
| 335 |
+
else
|
| 336 |
+
{
|
| 337 |
+
channel.lastBank = e;
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// add missing bank selects
|
| 342 |
+
// add all bank selects that are missing for this track
|
| 343 |
+
channelsInfo.forEach((has, ch) =>
|
| 344 |
+
{
|
| 345 |
+
if (has.hasBankSelect === true)
|
| 346 |
+
{
|
| 347 |
+
return;
|
| 348 |
+
}
|
| 349 |
+
// find the first program change (for the given channel)
|
| 350 |
+
const midiChannel = ch % 16;
|
| 351 |
+
const status = messageTypes.programChange | midiChannel;
|
| 352 |
+
// find track with this channel being used
|
| 353 |
+
const portOffset = Math.floor(ch / 16) * 16;
|
| 354 |
+
const port = mid.midiPortChannelOffsets.indexOf(portOffset);
|
| 355 |
+
const track = mid.tracks.find((t, tNum) => mid.midiPorts[tNum] === port && mid.usedChannelsOnTrack[tNum].has(
|
| 356 |
+
midiChannel));
|
| 357 |
+
if (track === undefined)
|
| 358 |
+
{
|
| 359 |
+
// this channel is not used at all
|
| 360 |
+
return;
|
| 361 |
+
}
|
| 362 |
+
let indexToAdd = track.findIndex(e => e.messageStatusByte === status);
|
| 363 |
+
if (indexToAdd === -1)
|
| 364 |
+
{
|
| 365 |
+
// no program change...
|
| 366 |
+
// add programs if they are missing from the track
|
| 367 |
+
// (need them to activate bank 1 for the embedded sfont)
|
| 368 |
+
const programIndex = track.findIndex(e => (e.messageStatusByte > 0x80 && e.messageStatusByte < 0xF0) && (e.messageStatusByte & 0xF) === midiChannel);
|
| 369 |
+
if (programIndex === -1)
|
| 370 |
+
{
|
| 371 |
+
// no voices??? skip
|
| 372 |
+
return;
|
| 373 |
+
}
|
| 374 |
+
const programTicks = track[programIndex].ticks;
|
| 375 |
+
const targetProgram = soundfont.getPreset(0, 0).program;
|
| 376 |
+
track.splice(programIndex, 0, new MIDIMessage(
|
| 377 |
+
programTicks,
|
| 378 |
+
messageTypes.programChange | midiChannel,
|
| 379 |
+
new IndexedByteArray([targetProgram])
|
| 380 |
+
));
|
| 381 |
+
indexToAdd = programIndex;
|
| 382 |
+
}
|
| 383 |
+
SpessaSynthInfo(
|
| 384 |
+
`%cAdding bank select for %c${ch}`,
|
| 385 |
+
consoleColors.info,
|
| 386 |
+
consoleColors.recognized
|
| 387 |
+
);
|
| 388 |
+
const ticks = track[indexToAdd].ticks;
|
| 389 |
+
const targetBank = (soundfont.getPreset(
|
| 390 |
+
0,
|
| 391 |
+
has.program,
|
| 392 |
+
isSystemXG(system)
|
| 393 |
+
)?.bank + bankOffset) || bankOffset;
|
| 394 |
+
track.splice(indexToAdd, 0, new MIDIMessage(
|
| 395 |
+
ticks,
|
| 396 |
+
messageTypes.controllerChange | midiChannel,
|
| 397 |
+
new IndexedByteArray([midiControllers.bankSelect, targetBank])
|
| 398 |
+
));
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
// make sure to put xg if gm
|
| 402 |
+
if (system !== "gs" && !isSystemXG(system))
|
| 403 |
+
{
|
| 404 |
+
for (const m of unwantedSystems)
|
| 405 |
+
{
|
| 406 |
+
mid.tracks[m.tNum].splice(mid.tracks[m.tNum].indexOf(m.e), 1);
|
| 407 |
+
}
|
| 408 |
+
let index = 0;
|
| 409 |
+
if (mid.tracks[0][0].messageStatusByte === messageTypes.trackName)
|
| 410 |
+
{
|
| 411 |
+
index++;
|
| 412 |
+
}
|
| 413 |
+
mid.tracks[0].splice(index, 0, getGsOn(0));
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
const newMid = new IndexedByteArray(mid.writeMIDI().buffer);
|
| 417 |
+
|
| 418 |
+
// info data for RMID
|
| 419 |
+
/**
|
| 420 |
+
* @type {Uint8Array[]}
|
| 421 |
+
*/
|
| 422 |
+
const infoContent = [getStringBytes("INFO")];
|
| 423 |
+
const encoder = new TextEncoder();
|
| 424 |
+
// software (SpessaSynth)
|
| 425 |
+
infoContent.push(
|
| 426 |
+
writeRIFFOddSize(RMIDINFOChunks.software, encoder.encode("SpessaSynth"), true)
|
| 427 |
+
);
|
| 428 |
+
// name
|
| 429 |
+
if (metadata.name !== undefined)
|
| 430 |
+
{
|
| 431 |
+
|
| 432 |
+
infoContent.push(
|
| 433 |
+
writeRIFFOddSize(RMIDINFOChunks.name, encoder.encode(metadata.name), true)
|
| 434 |
+
);
|
| 435 |
+
encoding = FORCED_ENCODING;
|
| 436 |
+
}
|
| 437 |
+
else
|
| 438 |
+
{
|
| 439 |
+
infoContent.push(
|
| 440 |
+
writeRIFFOddSize(RMIDINFOChunks.name, mid.rawMidiName, true)
|
| 441 |
+
);
|
| 442 |
+
}
|
| 443 |
+
// creation date
|
| 444 |
+
if (metadata.creationDate !== undefined)
|
| 445 |
+
{
|
| 446 |
+
encoding = FORCED_ENCODING;
|
| 447 |
+
infoContent.push(
|
| 448 |
+
writeRIFFOddSize(RMIDINFOChunks.creationDate, encoder.encode(metadata.creationDate), true)
|
| 449 |
+
);
|
| 450 |
+
}
|
| 451 |
+
else
|
| 452 |
+
{
|
| 453 |
+
const today = new Date().toLocaleString(undefined, {
|
| 454 |
+
weekday: "long",
|
| 455 |
+
year: "numeric",
|
| 456 |
+
month: "long",
|
| 457 |
+
day: "numeric",
|
| 458 |
+
hour: "numeric",
|
| 459 |
+
minute: "numeric"
|
| 460 |
+
});
|
| 461 |
+
infoContent.push(
|
| 462 |
+
writeRIFFOddSize(RMIDINFOChunks.creationDate, getStringBytesZero(today), true)
|
| 463 |
+
);
|
| 464 |
+
}
|
| 465 |
+
// comment
|
| 466 |
+
if (metadata.comment !== undefined)
|
| 467 |
+
{
|
| 468 |
+
encoding = FORCED_ENCODING;
|
| 469 |
+
infoContent.push(
|
| 470 |
+
writeRIFFOddSize(RMIDINFOChunks.comment, encoder.encode(metadata.comment))
|
| 471 |
+
);
|
| 472 |
+
}
|
| 473 |
+
// engineer
|
| 474 |
+
if (metadata.engineer !== undefined)
|
| 475 |
+
{
|
| 476 |
+
infoContent.push(
|
| 477 |
+
writeRIFFOddSize(RMIDINFOChunks.engineer, encoder.encode(metadata.engineer), true)
|
| 478 |
+
);
|
| 479 |
+
}
|
| 480 |
+
// album
|
| 481 |
+
if (metadata.album !== undefined)
|
| 482 |
+
{
|
| 483 |
+
// note that there are two album chunks: IPRD and IALB
|
| 484 |
+
encoding = FORCED_ENCODING;
|
| 485 |
+
infoContent.push(
|
| 486 |
+
writeRIFFOddSize(RMIDINFOChunks.album, encoder.encode(metadata.album), true)
|
| 487 |
+
);
|
| 488 |
+
infoContent.push(
|
| 489 |
+
writeRIFFOddSize(RMIDINFOChunks.album2, encoder.encode(metadata.album), true)
|
| 490 |
+
);
|
| 491 |
+
}
|
| 492 |
+
// artist
|
| 493 |
+
if (metadata.artist !== undefined)
|
| 494 |
+
{
|
| 495 |
+
encoding = FORCED_ENCODING;
|
| 496 |
+
infoContent.push(
|
| 497 |
+
writeRIFFOddSize(RMIDINFOChunks.artist, encoder.encode(metadata.artist), true)
|
| 498 |
+
);
|
| 499 |
+
}
|
| 500 |
+
// genre
|
| 501 |
+
if (metadata.genre !== undefined)
|
| 502 |
+
{
|
| 503 |
+
encoding = FORCED_ENCODING;
|
| 504 |
+
infoContent.push(
|
| 505 |
+
writeRIFFOddSize(RMIDINFOChunks.genre, encoder.encode(metadata.genre), true)
|
| 506 |
+
);
|
| 507 |
+
}
|
| 508 |
+
// picture
|
| 509 |
+
if (metadata.picture !== undefined)
|
| 510 |
+
{
|
| 511 |
+
infoContent.push(
|
| 512 |
+
writeRIFFOddSize(RMIDINFOChunks.picture, new Uint8Array(metadata.picture))
|
| 513 |
+
);
|
| 514 |
+
}
|
| 515 |
+
// copyright
|
| 516 |
+
if (metadata.copyright !== undefined)
|
| 517 |
+
{
|
| 518 |
+
encoding = FORCED_ENCODING;
|
| 519 |
+
infoContent.push(
|
| 520 |
+
writeRIFFOddSize(RMIDINFOChunks.copyright, encoder.encode(metadata.copyright), true)
|
| 521 |
+
);
|
| 522 |
+
}
|
| 523 |
+
else
|
| 524 |
+
{
|
| 525 |
+
// use midi copyright if possible
|
| 526 |
+
const copyright = mid.copyright.length > 0 ? mid.copyright : DEFAULT_COPYRIGHT;
|
| 527 |
+
infoContent.push(
|
| 528 |
+
writeRIFFOddSize(RMIDINFOChunks.copyright, getStringBytesZero(copyright))
|
| 529 |
+
);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// bank offset
|
| 533 |
+
const DBNK = new IndexedByteArray(2);
|
| 534 |
+
writeLittleEndian(DBNK, bankOffset, 2);
|
| 535 |
+
infoContent.push(writeRIFFOddSize(RMIDINFOChunks.bankOffset, DBNK));
|
| 536 |
+
// midi encoding
|
| 537 |
+
if (metadata.midiEncoding !== undefined)
|
| 538 |
+
{
|
| 539 |
+
infoContent.push(
|
| 540 |
+
writeRIFFOddSize(RMIDINFOChunks.midiEncoding, encoder.encode(metadata.midiEncoding))
|
| 541 |
+
);
|
| 542 |
+
encoding = FORCED_ENCODING;
|
| 543 |
+
}
|
| 544 |
+
// encoding
|
| 545 |
+
infoContent.push(writeRIFFOddSize(RMIDINFOChunks.encoding, getStringBytesZero(encoding)));
|
| 546 |
+
|
| 547 |
+
// combine and write out
|
| 548 |
+
const infodata = combineArrays(infoContent);
|
| 549 |
+
const rmiddata = combineArrays([
|
| 550 |
+
getStringBytes("RMID"),
|
| 551 |
+
writeRIFFOddSize(
|
| 552 |
+
"data",
|
| 553 |
+
newMid
|
| 554 |
+
),
|
| 555 |
+
writeRIFFOddSize(
|
| 556 |
+
"LIST",
|
| 557 |
+
infodata
|
| 558 |
+
),
|
| 559 |
+
soundfontBinary
|
| 560 |
+
]);
|
| 561 |
+
SpessaSynthInfo("%cFinished!", consoleColors.info);
|
| 562 |
+
SpessaSynthGroupEnd();
|
| 563 |
+
return writeRIFFOddSize(
|
| 564 |
+
"RIFF",
|
| 565 |
+
rmiddata
|
| 566 |
+
);
|
| 567 |
+
}
|
spessasynth_lib/midi_parser/used_keys_loaded.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SpessaSynthGroupCollapsed, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
|
| 2 |
+
import { consoleColors } from "../utils/other.js";
|
| 3 |
+
import { messageTypes, midiControllers } from "./midi_message.js";
|
| 4 |
+
import { DEFAULT_PERCUSSION } from "../synthetizer/synth_constants.js";
|
| 5 |
+
import { chooseBank, isSystemXG, parseBankSelect } from "../utils/xg_hacks.js";
|
| 6 |
+
import { isGSDrumsOn, isXGOn } from "../utils/sysex_detector.js";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Gets the used programs and keys for this MIDI file with a given sound bank
|
| 10 |
+
* @this {BasicMIDI}
|
| 11 |
+
* @param soundfont {BasicSoundBank|WorkletSoundfontManager} - the sound bank
|
| 12 |
+
* @returns {Object<string, Set<string>>}
|
| 13 |
+
*/
|
| 14 |
+
export function getUsedProgramsAndKeys(soundfont)
|
| 15 |
+
{
|
| 16 |
+
const mid = this;
|
| 17 |
+
SpessaSynthGroupCollapsed(
|
| 18 |
+
"%cSearching for all used programs and keys...",
|
| 19 |
+
consoleColors.info
|
| 20 |
+
);
|
| 21 |
+
// Find every bank:program combo and every key:velocity for each. Make sure to care about ports and drums
|
| 22 |
+
const channelsAmount = 16 + mid.midiPortChannelOffsets.reduce((max, cur) => cur > max ? cur : max);
|
| 23 |
+
/**
|
| 24 |
+
* @type {{program: number, bank: number, bankLSB: number, drums: boolean, string: string, actualBank: number}[]}
|
| 25 |
+
*/
|
| 26 |
+
const channelPresets = [];
|
| 27 |
+
for (let i = 0; i < channelsAmount; i++)
|
| 28 |
+
{
|
| 29 |
+
const bank = i % 16 === DEFAULT_PERCUSSION ? 128 : 0;
|
| 30 |
+
channelPresets.push({
|
| 31 |
+
program: 0,
|
| 32 |
+
bank: bank,
|
| 33 |
+
bankLSB: 0,
|
| 34 |
+
actualBank: bank,
|
| 35 |
+
drums: i % 16 === DEFAULT_PERCUSSION, // drums appear on 9 every 16 channels,
|
| 36 |
+
string: `${bank}:0`
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// check for xg
|
| 41 |
+
let system = "gs";
|
| 42 |
+
|
| 43 |
+
function updateString(ch)
|
| 44 |
+
{
|
| 45 |
+
const bank = chooseBank(ch.bank, ch.bankLSB, ch.drums, isSystemXG(system));
|
| 46 |
+
// check if this exists in the soundfont
|
| 47 |
+
let exists = soundfont.getPreset(bank, ch.program, isSystemXG(system));
|
| 48 |
+
ch.actualBank = exists.bank;
|
| 49 |
+
ch.program = exists.program;
|
| 50 |
+
ch.string = ch.actualBank + ":" + ch.program;
|
| 51 |
+
if (!usedProgramsAndKeys[ch.string])
|
| 52 |
+
{
|
| 53 |
+
SpessaSynthInfo(
|
| 54 |
+
`%cDetected a new preset: %c${ch.string}`,
|
| 55 |
+
consoleColors.info,
|
| 56 |
+
consoleColors.recognized
|
| 57 |
+
);
|
| 58 |
+
usedProgramsAndKeys[ch.string] = new Set();
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* find all programs used and key-velocity combos in them
|
| 64 |
+
* bank:program each has a set of midiNote-velocity
|
| 65 |
+
* @type {Object<string, Set<string>>}
|
| 66 |
+
*/
|
| 67 |
+
const usedProgramsAndKeys = {};
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* indexes for tracks
|
| 71 |
+
* @type {number[]}
|
| 72 |
+
*/
|
| 73 |
+
const eventIndexes = Array(mid.tracks.length).fill(0);
|
| 74 |
+
let remainingTracks = mid.tracks.length;
|
| 75 |
+
|
| 76 |
+
function findFirstEventIndex()
|
| 77 |
+
{
|
| 78 |
+
let index = 0;
|
| 79 |
+
let ticks = Infinity;
|
| 80 |
+
mid.tracks.forEach((track, i) =>
|
| 81 |
+
{
|
| 82 |
+
if (eventIndexes[i] >= track.length)
|
| 83 |
+
{
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
if (track[eventIndexes[i]].ticks < ticks)
|
| 87 |
+
{
|
| 88 |
+
index = i;
|
| 89 |
+
ticks = track[eventIndexes[i]].ticks;
|
| 90 |
+
}
|
| 91 |
+
});
|
| 92 |
+
return index;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const ports = mid.midiPorts.slice();
|
| 96 |
+
// initialize
|
| 97 |
+
channelPresets.forEach(c =>
|
| 98 |
+
{
|
| 99 |
+
updateString(c);
|
| 100 |
+
});
|
| 101 |
+
while (remainingTracks > 0)
|
| 102 |
+
{
|
| 103 |
+
let trackNum = findFirstEventIndex();
|
| 104 |
+
const track = mid.tracks[trackNum];
|
| 105 |
+
if (eventIndexes[trackNum] >= track.length)
|
| 106 |
+
{
|
| 107 |
+
remainingTracks--;
|
| 108 |
+
continue;
|
| 109 |
+
}
|
| 110 |
+
const event = track[eventIndexes[trackNum]];
|
| 111 |
+
eventIndexes[trackNum]++;
|
| 112 |
+
|
| 113 |
+
if (event.messageStatusByte === messageTypes.midiPort)
|
| 114 |
+
{
|
| 115 |
+
ports[trackNum] = event.messageData[0];
|
| 116 |
+
continue;
|
| 117 |
+
}
|
| 118 |
+
const status = event.messageStatusByte & 0xF0;
|
| 119 |
+
if (
|
| 120 |
+
status !== messageTypes.noteOn &&
|
| 121 |
+
status !== messageTypes.controllerChange &&
|
| 122 |
+
status !== messageTypes.programChange &&
|
| 123 |
+
status !== messageTypes.systemExclusive
|
| 124 |
+
)
|
| 125 |
+
{
|
| 126 |
+
continue;
|
| 127 |
+
}
|
| 128 |
+
const channel = (event.messageStatusByte & 0xF) + mid.midiPortChannelOffsets[ports[trackNum]] || 0;
|
| 129 |
+
let ch = channelPresets[channel];
|
| 130 |
+
switch (status)
|
| 131 |
+
{
|
| 132 |
+
case messageTypes.programChange:
|
| 133 |
+
ch.program = event.messageData[0];
|
| 134 |
+
updateString(ch);
|
| 135 |
+
break;
|
| 136 |
+
|
| 137 |
+
case messageTypes.controllerChange:
|
| 138 |
+
const isLSB = event.messageData[0] === midiControllers.lsbForControl0BankSelect;
|
| 139 |
+
if (event.messageData[0] !== midiControllers.bankSelect && !isLSB)
|
| 140 |
+
{
|
| 141 |
+
// we only care about bank select
|
| 142 |
+
continue;
|
| 143 |
+
}
|
| 144 |
+
if (system === "gs" && ch.drums)
|
| 145 |
+
{
|
| 146 |
+
// gs drums get changed via sysex, ignore here
|
| 147 |
+
continue;
|
| 148 |
+
}
|
| 149 |
+
const bank = event.messageData[1];
|
| 150 |
+
const realBank = Math.max(0, bank - mid.bankOffset);
|
| 151 |
+
if (isLSB)
|
| 152 |
+
{
|
| 153 |
+
ch.bankLSB = realBank;
|
| 154 |
+
}
|
| 155 |
+
else
|
| 156 |
+
{
|
| 157 |
+
ch.bank = realBank;
|
| 158 |
+
}
|
| 159 |
+
// interpret the bank
|
| 160 |
+
const intepretation = parseBankSelect(
|
| 161 |
+
ch.bank,
|
| 162 |
+
realBank,
|
| 163 |
+
system,
|
| 164 |
+
isLSB,
|
| 165 |
+
ch.drums,
|
| 166 |
+
channel
|
| 167 |
+
);
|
| 168 |
+
switch (intepretation.drumsStatus)
|
| 169 |
+
{
|
| 170 |
+
case 0:
|
| 171 |
+
// no change
|
| 172 |
+
break;
|
| 173 |
+
|
| 174 |
+
case 1:
|
| 175 |
+
// drums changed to off
|
| 176 |
+
// drum change is a program change
|
| 177 |
+
ch.drums = false;
|
| 178 |
+
updateString(ch);
|
| 179 |
+
break;
|
| 180 |
+
|
| 181 |
+
case 2:
|
| 182 |
+
// drums changed to on
|
| 183 |
+
// drum change is a program change
|
| 184 |
+
ch.drums = true;
|
| 185 |
+
updateString(ch);
|
| 186 |
+
break;
|
| 187 |
+
}
|
| 188 |
+
// do not update the data, bank change doesn't change the preset
|
| 189 |
+
break;
|
| 190 |
+
|
| 191 |
+
case messageTypes.noteOn:
|
| 192 |
+
if (event.messageData[1] === 0)
|
| 193 |
+
{
|
| 194 |
+
// that's a note off
|
| 195 |
+
continue;
|
| 196 |
+
}
|
| 197 |
+
usedProgramsAndKeys[ch.string].add(`${event.messageData[0]}-${event.messageData[1]}`);
|
| 198 |
+
break;
|
| 199 |
+
|
| 200 |
+
case messageTypes.systemExclusive:
|
| 201 |
+
// check for drum sysex
|
| 202 |
+
if (!isGSDrumsOn(event))
|
| 203 |
+
{
|
| 204 |
+
// check for XG
|
| 205 |
+
if (isXGOn(event))
|
| 206 |
+
{
|
| 207 |
+
system = "xg";
|
| 208 |
+
SpessaSynthInfo(
|
| 209 |
+
"%cXG on detected!",
|
| 210 |
+
consoleColors.recognized
|
| 211 |
+
);
|
| 212 |
+
}
|
| 213 |
+
continue;
|
| 214 |
+
}
|
| 215 |
+
const sysexChannel = [9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15][event.messageData[5] & 0x0F] + mid.midiPortChannelOffsets[ports[trackNum]];
|
| 216 |
+
const isDrum = !!(event.messageData[7] > 0 && event.messageData[5] >> 4);
|
| 217 |
+
ch = channelPresets[sysexChannel];
|
| 218 |
+
ch.drums = isDrum;
|
| 219 |
+
updateString(ch);
|
| 220 |
+
break;
|
| 221 |
+
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
for (const key of Object.keys(usedProgramsAndKeys))
|
| 225 |
+
{
|
| 226 |
+
if (usedProgramsAndKeys[key].size === 0)
|
| 227 |
+
{
|
| 228 |
+
SpessaSynthInfo(
|
| 229 |
+
`%cDetected change but no keys for %c${key}`,
|
| 230 |
+
consoleColors.info,
|
| 231 |
+
consoleColors.value
|
| 232 |
+
);
|
| 233 |
+
delete usedProgramsAndKeys[key];
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
SpessaSynthGroupEnd();
|
| 237 |
+
return usedProgramsAndKeys;
|
| 238 |
+
}
|
spessasynth_lib/midi_parser/xmf_loader.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readBytesAsString } from "../utils/byte_functions/string.js";
|
| 2 |
+
import { SpessaSynthGroup, SpessaSynthGroupEnd, SpessaSynthInfo, SpessaSynthWarn } from "../utils/loggin.js";
|
| 3 |
+
import { consoleColors } from "../utils/other.js";
|
| 4 |
+
import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
|
| 5 |
+
import { readVariableLengthQuantity } from "../utils/byte_functions/variable_length_quantity.js";
|
| 6 |
+
import { RMIDINFOChunks } from "./rmidi_writer.js";
|
| 7 |
+
import { inflateSync } from "../externals/fflate/fflate.min.js";
|
| 8 |
+
import { IndexedByteArray } from "../utils/indexed_array.js";
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* @enum {number}
|
| 12 |
+
*/
|
| 13 |
+
const metadataTypes = {
|
| 14 |
+
XMFFileType: 0,
|
| 15 |
+
nodeName: 1,
|
| 16 |
+
nodeIDNumber: 2,
|
| 17 |
+
resourceFormat: 3,
|
| 18 |
+
filenameOnDisk: 4,
|
| 19 |
+
filenameExtensionOnDisk: 5,
|
| 20 |
+
macOSFileTypeAndCreator: 6,
|
| 21 |
+
mimeType: 7,
|
| 22 |
+
title: 8,
|
| 23 |
+
copyrightNotice: 9,
|
| 24 |
+
comment: 10,
|
| 25 |
+
autoStart: 11, // Node Name of the FileNode containing the SMF image to autostart when the XMF file loads
|
| 26 |
+
preload: 12, // Used to preload specific SMF and DLS file images.
|
| 27 |
+
contentDescription: 13, // RP-42a (https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/rp42.pdf)
|
| 28 |
+
ID3Metadata: 14 // RP-47 (https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/rp47.pdf)
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* @enum {number}
|
| 33 |
+
*/
|
| 34 |
+
const referenceTypeIds = {
|
| 35 |
+
inLineResource: 1,
|
| 36 |
+
inFileResource: 2,
|
| 37 |
+
inFileNode: 3,
|
| 38 |
+
externalFile: 4,
|
| 39 |
+
externalXMF: 5,
|
| 40 |
+
XMFFileURIandNodeID: 6
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* @enum {number}
|
| 45 |
+
*/
|
| 46 |
+
const resourceFormatIDs = {
|
| 47 |
+
StandardMIDIFile: 0,
|
| 48 |
+
StandardMIDIFileType1: 1,
|
| 49 |
+
DLS1: 2,
|
| 50 |
+
DLS2: 3,
|
| 51 |
+
DLS22: 4,
|
| 52 |
+
mobileDLS: 5
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* @enum {number}
|
| 57 |
+
*/
|
| 58 |
+
const formatTypeIDs = {
|
| 59 |
+
standard: 0,
|
| 60 |
+
MMA: 1,
|
| 61 |
+
registered: 2,
|
| 62 |
+
nonRegistered: 3
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* @enum {number}
|
| 68 |
+
*/
|
| 69 |
+
const unpackerIDs = {
|
| 70 |
+
none: 0,
|
| 71 |
+
MMAUnpacker: 1,
|
| 72 |
+
registered: 2,
|
| 73 |
+
nonRegistered: 3
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
class XMFNode
|
| 77 |
+
{
|
| 78 |
+
/**
|
| 79 |
+
* @type {number}
|
| 80 |
+
*/
|
| 81 |
+
length;
|
| 82 |
+
/**
|
| 83 |
+
* 0 means it's a file node
|
| 84 |
+
* @type {number}
|
| 85 |
+
*/
|
| 86 |
+
itemCount;
|
| 87 |
+
/**
|
| 88 |
+
* @type {number}
|
| 89 |
+
*/
|
| 90 |
+
metadataLength;
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* @type {Object<string, any>}
|
| 94 |
+
*/
|
| 95 |
+
metadata = {};
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* @type {IndexedByteArray}
|
| 99 |
+
*/
|
| 100 |
+
nodeData;
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* @type {XMFNode[]}
|
| 104 |
+
*/
|
| 105 |
+
innerNodes = [];
|
| 106 |
+
|
| 107 |
+
packedContent = false;
|
| 108 |
+
|
| 109 |
+
nodeUnpackers = [];
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* @type {"StandardMIDIFile"|
|
| 114 |
+
* "StandardMIDIFileType1"|
|
| 115 |
+
* "DLS1"|
|
| 116 |
+
* "DLS2"|
|
| 117 |
+
* "DLS22"|
|
| 118 |
+
* "mobileDLS"|
|
| 119 |
+
* "unknown"|"folder"}
|
| 120 |
+
*/
|
| 121 |
+
resourceFormat = "unknown";
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* @param binaryData {IndexedByteArray}
|
| 125 |
+
*/
|
| 126 |
+
constructor(binaryData)
|
| 127 |
+
{
|
| 128 |
+
let nodeStartIndex = binaryData.currentIndex;
|
| 129 |
+
this.length = readVariableLengthQuantity(binaryData);
|
| 130 |
+
this.itemCount = readVariableLengthQuantity(binaryData);
|
| 131 |
+
// header length
|
| 132 |
+
const headerLength = readVariableLengthQuantity(binaryData);
|
| 133 |
+
const readBytes = binaryData.currentIndex - nodeStartIndex;
|
| 134 |
+
|
| 135 |
+
const remainingHeader = headerLength - readBytes;
|
| 136 |
+
const headerData = binaryData.slice(
|
| 137 |
+
binaryData.currentIndex,
|
| 138 |
+
binaryData.currentIndex + remainingHeader
|
| 139 |
+
);
|
| 140 |
+
binaryData.currentIndex += remainingHeader;
|
| 141 |
+
|
| 142 |
+
this.metadataLength = readVariableLengthQuantity(headerData);
|
| 143 |
+
|
| 144 |
+
const metadataChunk = headerData.slice(
|
| 145 |
+
headerData.currentIndex,
|
| 146 |
+
headerData.currentIndex + this.metadataLength
|
| 147 |
+
);
|
| 148 |
+
headerData.currentIndex += this.metadataLength;
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* @type {metadataTypes|string|number}
|
| 152 |
+
*/
|
| 153 |
+
let fieldSpecifier;
|
| 154 |
+
let key;
|
| 155 |
+
while (metadataChunk.currentIndex < metadataChunk.length)
|
| 156 |
+
{
|
| 157 |
+
const firstSpecifierByte = metadataChunk[metadataChunk.currentIndex];
|
| 158 |
+
if (firstSpecifierByte === 0)
|
| 159 |
+
{
|
| 160 |
+
metadataChunk.currentIndex++;
|
| 161 |
+
fieldSpecifier = readVariableLengthQuantity(metadataChunk);
|
| 162 |
+
if (Object.values(metadataTypes).indexOf(fieldSpecifier) === -1)
|
| 163 |
+
{
|
| 164 |
+
SpessaSynthWarn(`Unknown field specifier: ${fieldSpecifier}`);
|
| 165 |
+
key = `unknown_${fieldSpecifier}`;
|
| 166 |
+
}
|
| 167 |
+
else
|
| 168 |
+
{
|
| 169 |
+
key = Object.keys(metadataTypes).find(k => metadataTypes[k] === fieldSpecifier);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
else
|
| 173 |
+
{
|
| 174 |
+
// this is the length of string
|
| 175 |
+
const stringLength = readVariableLengthQuantity(metadataChunk);
|
| 176 |
+
fieldSpecifier = readBytesAsString(metadataChunk, stringLength);
|
| 177 |
+
key = fieldSpecifier;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const numberOfVersions = readVariableLengthQuantity(metadataChunk);
|
| 181 |
+
if (numberOfVersions === 0)
|
| 182 |
+
{
|
| 183 |
+
const dataLength = readVariableLengthQuantity(metadataChunk);
|
| 184 |
+
const contentsChunk = metadataChunk.slice(
|
| 185 |
+
metadataChunk.currentIndex,
|
| 186 |
+
metadataChunk.currentIndex + dataLength
|
| 187 |
+
);
|
| 188 |
+
metadataChunk.currentIndex += dataLength;
|
| 189 |
+
const formatID = readVariableLengthQuantity(contentsChunk);
|
| 190 |
+
// text only
|
| 191 |
+
if (formatID < 4)
|
| 192 |
+
{
|
| 193 |
+
this.metadata[key] = readBytesAsString(contentsChunk, dataLength - 1);
|
| 194 |
+
}
|
| 195 |
+
else
|
| 196 |
+
{
|
| 197 |
+
this.metadata[key] = contentsChunk.slice(contentsChunk.currentIndex);
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
else
|
| 201 |
+
{
|
| 202 |
+
// throw new Error ("International content is not supported.");
|
| 203 |
+
// Skip the number of versions
|
| 204 |
+
SpessaSynthWarn(`International content: ${numberOfVersions}`);
|
| 205 |
+
// Length in bytes
|
| 206 |
+
// Skip the whole thing!
|
| 207 |
+
metadataChunk.currentIndex += readVariableLengthQuantity(metadataChunk);
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const unpackersStart = headerData.currentIndex;
|
| 212 |
+
const unpackersLength = readVariableLengthQuantity(headerData);
|
| 213 |
+
const unpackersData = headerData.slice(headerData.currentIndex, unpackersStart + unpackersLength);
|
| 214 |
+
headerData.currentIndex = unpackersStart + unpackersLength;
|
| 215 |
+
if (unpackersLength > 0)
|
| 216 |
+
{
|
| 217 |
+
this.packedContent = true;
|
| 218 |
+
while (unpackersData.currentIndex < unpackersLength)
|
| 219 |
+
{
|
| 220 |
+
const unpacker = {};
|
| 221 |
+
unpacker.id = readVariableLengthQuantity(unpackersData);
|
| 222 |
+
switch (unpacker.id)
|
| 223 |
+
{
|
| 224 |
+
case unpackerIDs.nonRegistered:
|
| 225 |
+
case unpackerIDs.registered:
|
| 226 |
+
SpessaSynthGroupEnd();
|
| 227 |
+
throw new Error(`Unsupported unpacker ID: ${unpacker.id}`);
|
| 228 |
+
|
| 229 |
+
default:
|
| 230 |
+
SpessaSynthGroupEnd();
|
| 231 |
+
throw new Error(`Unknown unpacker ID: ${unpacker.id}`);
|
| 232 |
+
|
| 233 |
+
case unpackerIDs.none:
|
| 234 |
+
unpacker.standardID = readVariableLengthQuantity(unpackersData);
|
| 235 |
+
break;
|
| 236 |
+
|
| 237 |
+
case unpackerIDs.MMAUnpacker:
|
| 238 |
+
let manufacturerID = unpackersData[unpackersData.currentIndex++];
|
| 239 |
+
// one or three byte form, depending on if the first byte is zero
|
| 240 |
+
if (manufacturerID === 0)
|
| 241 |
+
{
|
| 242 |
+
manufacturerID <<= 8;
|
| 243 |
+
manufacturerID |= unpackersData[unpackersData.currentIndex++];
|
| 244 |
+
manufacturerID <<= 8;
|
| 245 |
+
manufacturerID |= unpackersData[unpackersData.currentIndex++];
|
| 246 |
+
}
|
| 247 |
+
const manufacturerInternalID = readVariableLengthQuantity(unpackersData);
|
| 248 |
+
unpacker.manufacturerID = manufacturerID;
|
| 249 |
+
unpacker.manufacturerInternalID = manufacturerInternalID;
|
| 250 |
+
break;
|
| 251 |
+
}
|
| 252 |
+
unpacker.decodedSize = readVariableLengthQuantity(unpackersData);
|
| 253 |
+
this.nodeUnpackers.push(unpacker);
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
binaryData.currentIndex = nodeStartIndex + headerLength;
|
| 257 |
+
/**
|
| 258 |
+
* @type {referenceTypeIds|number}
|
| 259 |
+
*/
|
| 260 |
+
this.referenceTypeID = readVariableLengthQuantity(binaryData);
|
| 261 |
+
this.nodeData = binaryData.slice(binaryData.currentIndex, nodeStartIndex + this.length);
|
| 262 |
+
binaryData.currentIndex = nodeStartIndex + this.length;
|
| 263 |
+
switch (this.referenceTypeID)
|
| 264 |
+
{
|
| 265 |
+
case referenceTypeIds.inLineResource:
|
| 266 |
+
break;
|
| 267 |
+
|
| 268 |
+
case referenceTypeIds.externalXMF:
|
| 269 |
+
case referenceTypeIds.inFileNode:
|
| 270 |
+
case referenceTypeIds.XMFFileURIandNodeID:
|
| 271 |
+
case referenceTypeIds.externalFile:
|
| 272 |
+
case referenceTypeIds.inFileResource:
|
| 273 |
+
SpessaSynthGroupEnd();
|
| 274 |
+
throw new Error(`Unsupported reference type: ${this.referenceTypeID}`);
|
| 275 |
+
|
| 276 |
+
default:
|
| 277 |
+
SpessaSynthGroupEnd();
|
| 278 |
+
throw new Error(`Unknown reference type: ${this.referenceTypeID}`);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// read the data
|
| 282 |
+
if (this.isFile)
|
| 283 |
+
{
|
| 284 |
+
if (this.packedContent)
|
| 285 |
+
{
|
| 286 |
+
const compressed = this.nodeData.slice(2, this.nodeData.length);
|
| 287 |
+
SpessaSynthInfo(
|
| 288 |
+
`%cPacked content. Attemting to deflate. Target size: %c${this.nodeUnpackers[0].decodedSize}`,
|
| 289 |
+
consoleColors.warn,
|
| 290 |
+
consoleColors.value
|
| 291 |
+
);
|
| 292 |
+
try
|
| 293 |
+
{
|
| 294 |
+
this.nodeData = new IndexedByteArray(inflateSync(compressed).buffer);
|
| 295 |
+
}
|
| 296 |
+
catch (e)
|
| 297 |
+
{
|
| 298 |
+
SpessaSynthGroupEnd();
|
| 299 |
+
throw new Error(`Error unpacking XMF file contents: ${e.message}.`);
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
/**
|
| 303 |
+
* interpret the content
|
| 304 |
+
* @type {number[]}
|
| 305 |
+
*/
|
| 306 |
+
const resourceFormat = this.metadata["resourceFormat"];
|
| 307 |
+
if (resourceFormat === undefined)
|
| 308 |
+
{
|
| 309 |
+
SpessaSynthWarn("No resource format for this file node!");
|
| 310 |
+
}
|
| 311 |
+
else
|
| 312 |
+
{
|
| 313 |
+
const formatTypeID = resourceFormat[0];
|
| 314 |
+
if (formatTypeID !== formatTypeIDs.standard)
|
| 315 |
+
{
|
| 316 |
+
SpessaSynthWarn(`Non-standard formatTypeID: ${resourceFormat}`);
|
| 317 |
+
this.resourceFormat = resourceFormat.toString();
|
| 318 |
+
}
|
| 319 |
+
const resourceFormatID = resourceFormat[1];
|
| 320 |
+
if (Object.values(resourceFormatIDs).indexOf(resourceFormatID) === -1)
|
| 321 |
+
{
|
| 322 |
+
SpessaSynthWarn(`Unrecognized resource format: ${resourceFormatID}`);
|
| 323 |
+
}
|
| 324 |
+
else
|
| 325 |
+
{
|
| 326 |
+
this.resourceFormat = Object.keys(resourceFormatIDs)
|
| 327 |
+
.find(k => resourceFormatIDs[k] === resourceFormatID);
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
else
|
| 332 |
+
{
|
| 333 |
+
// folder node
|
| 334 |
+
this.resourceFormat = "folder";
|
| 335 |
+
while (this.nodeData.currentIndex < this.nodeData.length)
|
| 336 |
+
{
|
| 337 |
+
const nodeStartIndex = this.nodeData.currentIndex;
|
| 338 |
+
const nodeLength = readVariableLengthQuantity(this.nodeData);
|
| 339 |
+
const nodeData = this.nodeData.slice(nodeStartIndex, nodeStartIndex + nodeLength);
|
| 340 |
+
this.nodeData.currentIndex = nodeStartIndex + nodeLength;
|
| 341 |
+
this.innerNodes.push(new XMFNode(nodeData));
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
get isFile()
|
| 347 |
+
{
|
| 348 |
+
return this.itemCount === 0;
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/**
|
| 353 |
+
* @param midi {MIDI}
|
| 354 |
+
* @param binaryData {IndexedByteArray}
|
| 355 |
+
* @returns {IndexedByteArray} the file byte array
|
| 356 |
+
*/
|
| 357 |
+
export function loadXMF(midi, binaryData)
|
| 358 |
+
{
|
| 359 |
+
midi.bankOffset = 0;
|
| 360 |
+
// https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/xmf-v1a.pdf
|
| 361 |
+
// https://wiki.multimedia.cx/index.php?title=Extensible_Music_Format_(XMF)
|
| 362 |
+
const sanityCheck = readBytesAsString(binaryData, 4);
|
| 363 |
+
if (sanityCheck !== "XMF_")
|
| 364 |
+
{
|
| 365 |
+
SpessaSynthGroupEnd();
|
| 366 |
+
throw new SyntaxError(`Invalid XMF Header! Expected "_XMF", got "${sanityCheck}"`);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
SpessaSynthGroup("%cParsing XMF file...", consoleColors.info);
|
| 370 |
+
const version = readBytesAsString(binaryData, 4);
|
| 371 |
+
SpessaSynthInfo(
|
| 372 |
+
`%cXMF version: %c${version}`,
|
| 373 |
+
consoleColors.info, consoleColors.recognized
|
| 374 |
+
);
|
| 375 |
+
// https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/rp43.pdf
|
| 376 |
+
// version 2.00 has additional bytes
|
| 377 |
+
if (version === "2.00")
|
| 378 |
+
{
|
| 379 |
+
const fileTypeId = readBytesAsUintBigEndian(binaryData, 4);
|
| 380 |
+
const fileTypeRevisionId = readBytesAsUintBigEndian(binaryData, 4);
|
| 381 |
+
SpessaSynthInfo(
|
| 382 |
+
`%cFile Type ID: %c${fileTypeId}%c, File Type Revision ID: %c${fileTypeRevisionId}`,
|
| 383 |
+
consoleColors.info,
|
| 384 |
+
consoleColors.recognized,
|
| 385 |
+
consoleColors.info,
|
| 386 |
+
consoleColors.recognized
|
| 387 |
+
);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
// file length
|
| 391 |
+
readVariableLengthQuantity(binaryData);
|
| 392 |
+
|
| 393 |
+
const metadataTableLength = readVariableLengthQuantity(binaryData);
|
| 394 |
+
// skip metadata
|
| 395 |
+
binaryData.currentIndex += metadataTableLength;
|
| 396 |
+
|
| 397 |
+
// skip to tree root
|
| 398 |
+
binaryData.currentIndex = readVariableLengthQuantity(binaryData);
|
| 399 |
+
const rootNode = new XMFNode(binaryData);
|
| 400 |
+
/**
|
| 401 |
+
* @type {IndexedByteArray}
|
| 402 |
+
*/
|
| 403 |
+
let midiArray;
|
| 404 |
+
/**
|
| 405 |
+
* find the stuff we care about
|
| 406 |
+
* @param node {XMFNode}
|
| 407 |
+
*/
|
| 408 |
+
const searchNode = node =>
|
| 409 |
+
{
|
| 410 |
+
const checkMeta = (xmf, rmid) =>
|
| 411 |
+
{
|
| 412 |
+
if (node.metadata[xmf] !== undefined && typeof node.metadata[xmf] === "string")
|
| 413 |
+
{
|
| 414 |
+
midi.RMIDInfo[rmid] = node.metadata[xmf];
|
| 415 |
+
}
|
| 416 |
+
};
|
| 417 |
+
// meta
|
| 418 |
+
checkMeta("nodeName", RMIDINFOChunks.name);
|
| 419 |
+
checkMeta("title", RMIDINFOChunks.name);
|
| 420 |
+
checkMeta("copyrightNotice", RMIDINFOChunks.copyright);
|
| 421 |
+
checkMeta("comment", RMIDINFOChunks.comment);
|
| 422 |
+
if (node.isFile)
|
| 423 |
+
{
|
| 424 |
+
switch (node.resourceFormat)
|
| 425 |
+
{
|
| 426 |
+
default:
|
| 427 |
+
return;
|
| 428 |
+
case "DLS1":
|
| 429 |
+
case "DLS2":
|
| 430 |
+
case "DLS22":
|
| 431 |
+
case "mobileDLS":
|
| 432 |
+
SpessaSynthInfo("%cFound embedded DLS!", consoleColors.recognized);
|
| 433 |
+
midi.embeddedSoundFont = node.nodeData.buffer;
|
| 434 |
+
break;
|
| 435 |
+
|
| 436 |
+
case "StandardMIDIFile":
|
| 437 |
+
case "StandardMIDIFileType1":
|
| 438 |
+
SpessaSynthInfo("%cFound embedded MIDI!", consoleColors.recognized);
|
| 439 |
+
midiArray = node.nodeData;
|
| 440 |
+
break;
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
else
|
| 444 |
+
{
|
| 445 |
+
for (const n of node.innerNodes)
|
| 446 |
+
{
|
| 447 |
+
searchNode(n);
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
};
|
| 451 |
+
searchNode(rootNode);
|
| 452 |
+
SpessaSynthGroupEnd();
|
| 453 |
+
return midiArray;
|
| 454 |
+
}
|
spessasynth_lib/sequencer/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## This is the sequencer's folder.
|
| 2 |
+
|
| 3 |
+
The code here is responsible for playing back the parsed MIDI sequence with the synthesizer.
|
| 4 |
+
|
| 5 |
+
### Message protocol:
|
| 6 |
+
|
| 7 |
+
#### Message structure
|
| 8 |
+
|
| 9 |
+
```js
|
| 10 |
+
const message = {
|
| 11 |
+
messageType: number, // WorkletSequencerMessageType
|
| 12 |
+
messageData: any // any
|
| 13 |
+
}
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
#### To worklet
|
| 17 |
+
|
| 18 |
+
Sequencer uses `Synthetizer`'s `post` method to post a message with `messageData` set to
|
| 19 |
+
`workletMessageType.sequencerSpecific`.
|
| 20 |
+
The `messageData` is set to the sequencer's message.
|
| 21 |
+
|
| 22 |
+
#### From worklet
|
| 23 |
+
|
| 24 |
+
`WorkletSequencer` uses `SpessaSynthProcessor`'s post to send a message with `messageData` set to
|
| 25 |
+
`returnMessageType.sequencerSpecific`.
|
| 26 |
+
The `messageData` is set to the sequencer's return message.
|
| 27 |
+
|
| 28 |
+
### Process tick
|
| 29 |
+
|
| 30 |
+
`processTick` is called every time the `process` method is called via `SpessaSynthProcessor.processTickCallback`.
|
spessasynth_lib/sequencer/default_sequencer_options.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @type {SequencerOptions}
|
| 3 |
+
*/
|
| 4 |
+
export const DEFAULT_SEQUENCER_OPTIONS = {
|
| 5 |
+
skipToFirstNoteOn: true,
|
| 6 |
+
autoPlay: true,
|
| 7 |
+
preservePlaybackState: false
|
| 8 |
+
};
|
spessasynth_lib/sequencer/sequencer.js
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Synthetizer } from "../synthetizer/synthetizer.js";
|
| 2 |
+
import { messageTypes } from "../midi_parser/midi_message.js";
|
| 3 |
+
import { workletMessageType } from "../synthetizer/worklet_system/message_protocol/worklet_message.js";
|
| 4 |
+
import {
|
| 5 |
+
SongChangeType,
|
| 6 |
+
WorkletSequencerMessageType,
|
| 7 |
+
WorkletSequencerReturnMessageType
|
| 8 |
+
} from "./worklet_sequencer/sequencer_message.js";
|
| 9 |
+
import { SpessaSynthWarn } from "../utils/loggin.js";
|
| 10 |
+
import { DUMMY_MIDI_DATA, MIDIData } from "../midi_parser/midi_data.js";
|
| 11 |
+
import { BasicMIDI } from "../midi_parser/basic_midi.js";
|
| 12 |
+
import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
|
| 13 |
+
import { DEFAULT_SEQUENCER_OPTIONS } from "./default_sequencer_options.js";
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* sequencer.js
|
| 17 |
+
* purpose: plays back the midi file decoded by midi_loader.js, including support for multichannel midis
|
| 18 |
+
* (adding channels when more than one midi port is detected)
|
| 19 |
+
* note: this is the sequencer class that runs on the main thread
|
| 20 |
+
* and only communicates with the worklet sequencer which does the actual playback
|
| 21 |
+
*/
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* @typedef MidFile {Object}
|
| 25 |
+
* @property {ArrayBuffer} binary - the binary data of the file.
|
| 26 |
+
* @property {string|undefined} altName - the alternative name for the file
|
| 27 |
+
*/
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* @typedef {BasicMIDI|MidFile} MIDIFile
|
| 31 |
+
*/
|
| 32 |
+
|
| 33 |
+
// noinspection JSUnusedGlobalSymbols
|
| 34 |
+
/**
|
| 35 |
+
* @typedef {Object} SequencerOptions
|
| 36 |
+
* @property {boolean|undefined} skipToFirstNoteOn - if true, the sequencer will skip to the first note
|
| 37 |
+
* @property {boolean|undefined} autoPlay - if true, the sequencer will automatically start playing the MIDI
|
| 38 |
+
* @property {boolean|unescape} preservePlaybackState - if true,
|
| 39 |
+
* the sequencer will stay paused when seeking or changing the playback rate
|
| 40 |
+
*/
|
| 41 |
+
|
| 42 |
+
// noinspection JSUnusedGlobalSymbols
|
| 43 |
+
export class Sequencer
|
| 44 |
+
{
|
| 45 |
+
/**
|
| 46 |
+
* Executes when MIDI parsing has an error.
|
| 47 |
+
* @type {function(Error)}
|
| 48 |
+
*/
|
| 49 |
+
onError;
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Fires on text event
|
| 53 |
+
* @type {Function}
|
| 54 |
+
* @param data {Uint8Array} the data text
|
| 55 |
+
* @param type {number} the status byte of the message (the meta-status byte)
|
| 56 |
+
* @param lyricsIndex {number} if the text is a lyric, the index of the lyric in midiData.lyrics, otherwise -1
|
| 57 |
+
*/
|
| 58 |
+
onTextEvent;
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* The current MIDI data, with the exclusion of the embedded sound bank and event data.
|
| 62 |
+
* @type {MIDIData}
|
| 63 |
+
*/
|
| 64 |
+
midiData;
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* The current MIDI data for all songs, like the midiData property.
|
| 68 |
+
* @type {MIDIData[]}
|
| 69 |
+
*/
|
| 70 |
+
songListData = [];
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* @type {Object<string, function(MIDIData)>}
|
| 74 |
+
* @private
|
| 75 |
+
*/
|
| 76 |
+
onSongChange = {};
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Fires when CurrentTime changes
|
| 80 |
+
* @type {Object<string, function(number)>} the time that was changed to
|
| 81 |
+
* @private
|
| 82 |
+
*/
|
| 83 |
+
onTimeChange = {};
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* @type {Object<string, function>}
|
| 87 |
+
* @private
|
| 88 |
+
*/
|
| 89 |
+
onSongEnded = {};
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Fires on tempo change
|
| 93 |
+
* @type {Object<string, function(number)>}
|
| 94 |
+
*/
|
| 95 |
+
onTempoChange = {};
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Fires on meta-event
|
| 99 |
+
* @type {Object<string, function([number, Uint8Array, number, number])>}
|
| 100 |
+
*/
|
| 101 |
+
onMetaEvent = {};
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Current song's tempo in BPM
|
| 105 |
+
* @type {number}
|
| 106 |
+
*/
|
| 107 |
+
currentTempo = 120;
|
| 108 |
+
/**
|
| 109 |
+
* Current song index
|
| 110 |
+
* @type {number}
|
| 111 |
+
*/
|
| 112 |
+
songIndex = 0;
|
| 113 |
+
/**
|
| 114 |
+
* @type {function(BasicMIDI)}
|
| 115 |
+
* @private
|
| 116 |
+
*/
|
| 117 |
+
_getMIDIResolve = undefined;
|
| 118 |
+
/**
|
| 119 |
+
* Indicates if the current midiData property has fake data in it (not yet loaded)
|
| 120 |
+
* @type {boolean}
|
| 121 |
+
*/
|
| 122 |
+
hasDummyData = true;
|
| 123 |
+
/**
|
| 124 |
+
* Indicates whether the sequencer has finished playing a sequence
|
| 125 |
+
* @type {boolean}
|
| 126 |
+
*/
|
| 127 |
+
isFinished = false;
|
| 128 |
+
/**
|
| 129 |
+
* The current sequence's length, in seconds
|
| 130 |
+
* @type {number}
|
| 131 |
+
*/
|
| 132 |
+
duration = 0;
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* Indicates if the sequencer is paused.
|
| 136 |
+
* Paused if a number, undefined if playing
|
| 137 |
+
* @type {undefined|number}
|
| 138 |
+
* @private
|
| 139 |
+
*/
|
| 140 |
+
pausedTime = undefined;
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Creates a new Midi sequencer for playing back MIDI files
|
| 144 |
+
* @param midiBinaries {MIDIFile[]} List of the buffers of the MIDI files
|
| 145 |
+
* @param synth {Synthetizer} synth to send events to
|
| 146 |
+
* @param options {SequencerOptions} the sequencer's options
|
| 147 |
+
*/
|
| 148 |
+
constructor(midiBinaries, synth, options = DEFAULT_SEQUENCER_OPTIONS)
|
| 149 |
+
{
|
| 150 |
+
this.ignoreEvents = false;
|
| 151 |
+
this.synth = synth;
|
| 152 |
+
this.highResTimeOffset = 0;
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* Absolute playback startTime, bases on the synth's time
|
| 156 |
+
* @type {number}
|
| 157 |
+
*/
|
| 158 |
+
this.absoluteStartTime = this.synth.currentTime;
|
| 159 |
+
|
| 160 |
+
this.synth.sequencerCallbackFunction = this._handleMessage.bind(this);
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* @type {boolean}
|
| 164 |
+
* @private
|
| 165 |
+
*/
|
| 166 |
+
this._skipToFirstNoteOn = options?.skipToFirstNoteOn ?? true;
|
| 167 |
+
/**
|
| 168 |
+
* @type {boolean}
|
| 169 |
+
* @private
|
| 170 |
+
*/
|
| 171 |
+
this._preservePlaybackState = options?.preservePlaybackState ?? false;
|
| 172 |
+
|
| 173 |
+
if (this._skipToFirstNoteOn === false)
|
| 174 |
+
{
|
| 175 |
+
// setter sends message
|
| 176 |
+
this._sendMessage(WorkletSequencerMessageType.setSkipToFirstNote, false);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if (this._preservePlaybackState === true)
|
| 180 |
+
{
|
| 181 |
+
this._sendMessage(WorkletSequencerMessageType.setPreservePlaybackState, true);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
this.loadNewSongList(midiBinaries, options?.autoPlay ?? true);
|
| 185 |
+
|
| 186 |
+
window.addEventListener("beforeunload", this.resetMIDIOut.bind(this));
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* Internal loop marker
|
| 191 |
+
* @type {boolean}
|
| 192 |
+
* @private
|
| 193 |
+
*/
|
| 194 |
+
_loop = true;
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* Indicates if the sequencer is currently looping
|
| 198 |
+
* @returns {boolean}
|
| 199 |
+
*/
|
| 200 |
+
get loop()
|
| 201 |
+
{
|
| 202 |
+
return this._loop;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
set loop(value)
|
| 206 |
+
{
|
| 207 |
+
this._sendMessage(WorkletSequencerMessageType.setLoop, [value, this._loopsRemaining]);
|
| 208 |
+
this._loop = value;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/**
|
| 212 |
+
* Internal loop count marker (-1 is infinite)
|
| 213 |
+
* @type {number}
|
| 214 |
+
* @private
|
| 215 |
+
*/
|
| 216 |
+
_loopsRemaining = -1;
|
| 217 |
+
|
| 218 |
+
/**
|
| 219 |
+
* The current remaining number of loops. -1 means infinite looping
|
| 220 |
+
* @returns {number}
|
| 221 |
+
*/
|
| 222 |
+
get loopsRemaining()
|
| 223 |
+
{
|
| 224 |
+
return this._loopsRemaining;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* The current remaining number of loops. -1 means infinite looping
|
| 229 |
+
* @param val {number}
|
| 230 |
+
*/
|
| 231 |
+
set loopsRemaining(val)
|
| 232 |
+
{
|
| 233 |
+
this._loopsRemaining = val;
|
| 234 |
+
this._sendMessage(WorkletSequencerMessageType.setLoop, [this._loop, val]);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Controls the playback's rate
|
| 239 |
+
* @type {number}
|
| 240 |
+
* @private
|
| 241 |
+
*/
|
| 242 |
+
_playbackRate = 1;
|
| 243 |
+
|
| 244 |
+
/**
|
| 245 |
+
* @returns {number}
|
| 246 |
+
*/
|
| 247 |
+
get playbackRate()
|
| 248 |
+
{
|
| 249 |
+
return this._playbackRate;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* @param value {number}
|
| 254 |
+
*/
|
| 255 |
+
set playbackRate(value)
|
| 256 |
+
{
|
| 257 |
+
this._sendMessage(WorkletSequencerMessageType.setPlaybackRate, value);
|
| 258 |
+
this.highResTimeOffset *= (value / this._playbackRate);
|
| 259 |
+
this._playbackRate = value;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/**
|
| 263 |
+
* @type {boolean}
|
| 264 |
+
* @private
|
| 265 |
+
*/
|
| 266 |
+
_shuffleSongs = false;
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* Indicates if the song order is random
|
| 270 |
+
* @returns {boolean}
|
| 271 |
+
*/
|
| 272 |
+
get shuffleSongs()
|
| 273 |
+
{
|
| 274 |
+
return this._shuffleSongs;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* Indicates if the song order is random
|
| 279 |
+
* @param value {boolean}
|
| 280 |
+
*/
|
| 281 |
+
set shuffleSongs(value)
|
| 282 |
+
{
|
| 283 |
+
this._shuffleSongs = value;
|
| 284 |
+
if (value)
|
| 285 |
+
{
|
| 286 |
+
this._sendMessage(WorkletSequencerMessageType.changeSong, [SongChangeType.shuffleOn]);
|
| 287 |
+
}
|
| 288 |
+
else
|
| 289 |
+
{
|
| 290 |
+
this._sendMessage(WorkletSequencerMessageType.changeSong, [SongChangeType.shuffleOff]);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/**
|
| 295 |
+
* Indicates if the sequencer should skip to first note on
|
| 296 |
+
* @return {boolean}
|
| 297 |
+
*/
|
| 298 |
+
get skipToFirstNoteOn()
|
| 299 |
+
{
|
| 300 |
+
return this._skipToFirstNoteOn;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/**
|
| 304 |
+
* Indicates if the sequencer should skip to first note on
|
| 305 |
+
* @param val {boolean}
|
| 306 |
+
*/
|
| 307 |
+
set skipToFirstNoteOn(val)
|
| 308 |
+
{
|
| 309 |
+
this._skipToFirstNoteOn = val;
|
| 310 |
+
this._sendMessage(WorkletSequencerMessageType.setSkipToFirstNote, this._skipToFirstNoteOn);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* if true,
|
| 315 |
+
* the sequencer will stay paused when seeking or changing the playback rate
|
| 316 |
+
* @returns {boolean}
|
| 317 |
+
*/
|
| 318 |
+
get preservePlaybackState()
|
| 319 |
+
{
|
| 320 |
+
return this._preservePlaybackState;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* if true,
|
| 325 |
+
* the sequencer will stay paused when seeking or changing the playback rate
|
| 326 |
+
* @param val {boolean}
|
| 327 |
+
*/
|
| 328 |
+
set preservePlaybackState(val)
|
| 329 |
+
{
|
| 330 |
+
this._preservePlaybackState = val;
|
| 331 |
+
this._sendMessage(WorkletSequencerMessageType.setPreservePlaybackState, val);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/**
|
| 335 |
+
* @returns {number} Current playback time, in seconds
|
| 336 |
+
*/
|
| 337 |
+
get currentTime()
|
| 338 |
+
{
|
| 339 |
+
// return the paused time if it's set to something other than undefined
|
| 340 |
+
if (this.pausedTime !== undefined)
|
| 341 |
+
{
|
| 342 |
+
return this.pausedTime;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
return (this.synth.currentTime - this.absoluteStartTime) * this._playbackRate;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
set currentTime(time)
|
| 349 |
+
{
|
| 350 |
+
if (!this._preservePlaybackState)
|
| 351 |
+
{
|
| 352 |
+
this.unpause();
|
| 353 |
+
}
|
| 354 |
+
this._sendMessage(WorkletSequencerMessageType.setTime, time);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/**
|
| 358 |
+
* Use for visualization as it's not affected by the audioContext stutter
|
| 359 |
+
* @returns {number}
|
| 360 |
+
*/
|
| 361 |
+
get currentHighResolutionTime()
|
| 362 |
+
{
|
| 363 |
+
if (this.pausedTime !== undefined)
|
| 364 |
+
{
|
| 365 |
+
return this.pausedTime;
|
| 366 |
+
}
|
| 367 |
+
const highResTimeOffset = this.highResTimeOffset;
|
| 368 |
+
const absoluteStartTime = this.absoluteStartTime;
|
| 369 |
+
|
| 370 |
+
// sync performance.now to current time
|
| 371 |
+
const performanceElapsedTime = ((performance.now() / 1000) - absoluteStartTime) * this._playbackRate;
|
| 372 |
+
|
| 373 |
+
let currentPerformanceTime = highResTimeOffset + performanceElapsedTime;
|
| 374 |
+
const currentAudioTime = this.currentTime;
|
| 375 |
+
|
| 376 |
+
const smoothingFactor = 0.01 * this._playbackRate;
|
| 377 |
+
|
| 378 |
+
// diff times smoothing factor
|
| 379 |
+
const timeDifference = currentAudioTime - currentPerformanceTime;
|
| 380 |
+
this.highResTimeOffset += timeDifference * smoothingFactor;
|
| 381 |
+
|
| 382 |
+
// return a smoothed performance time
|
| 383 |
+
currentPerformanceTime = this.highResTimeOffset + performanceElapsedTime;
|
| 384 |
+
return currentPerformanceTime;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/**
|
| 388 |
+
* true if paused, false if playing or stopped
|
| 389 |
+
* @returns {boolean}
|
| 390 |
+
*/
|
| 391 |
+
get paused()
|
| 392 |
+
{
|
| 393 |
+
return this.pausedTime !== undefined;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/**
|
| 397 |
+
* Adds a new event that gets called when the song changes
|
| 398 |
+
* @param callback {function(MIDIData)}
|
| 399 |
+
* @param id {string} must be unique
|
| 400 |
+
*/
|
| 401 |
+
addOnSongChangeEvent(callback, id)
|
| 402 |
+
{
|
| 403 |
+
this.onSongChange[id] = callback;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/**
|
| 407 |
+
* Adds a new event that gets called when the song ends
|
| 408 |
+
* @param callback {function}
|
| 409 |
+
* @param id {string} must be unique
|
| 410 |
+
*/
|
| 411 |
+
addOnSongEndedEvent(callback, id)
|
| 412 |
+
{
|
| 413 |
+
this.onSongEnded[id] = callback;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
/**
|
| 417 |
+
* Adds a new event that gets called when the time changes
|
| 418 |
+
* @param callback {function(number)} the new time, in seconds
|
| 419 |
+
* @param id {string} must be unique
|
| 420 |
+
*/
|
| 421 |
+
addOnTimeChangeEvent(callback, id)
|
| 422 |
+
{
|
| 423 |
+
this.onTimeChange[id] = callback;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* Adds a new event that gets called when the tempo changes
|
| 428 |
+
* @param callback {function(number)} the new tempo, in BPM
|
| 429 |
+
* @param id {string} must be unique
|
| 430 |
+
*/
|
| 431 |
+
addOnTempoChangeEvent(callback, id)
|
| 432 |
+
{
|
| 433 |
+
this.onTempoChange[id] = callback;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/**
|
| 437 |
+
* Adds a new event that gets called when a meta-event occurs
|
| 438 |
+
* @param callback {function([number, Uint8Array, number, number])} the meta-event type,
|
| 439 |
+
* its data, the track number and MIDI ticks
|
| 440 |
+
* @param id {string} must be unique
|
| 441 |
+
*/
|
| 442 |
+
addOnMetaEvent(callback, id)
|
| 443 |
+
{
|
| 444 |
+
this.onMetaEvent[id] = callback;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
resetMIDIOut()
|
| 448 |
+
{
|
| 449 |
+
if (!this.MIDIout)
|
| 450 |
+
{
|
| 451 |
+
return;
|
| 452 |
+
}
|
| 453 |
+
for (let i = 0; i < 16; i++)
|
| 454 |
+
{
|
| 455 |
+
this.MIDIout.send([messageTypes.controllerChange | i, 120, 0]); // all notes off
|
| 456 |
+
this.MIDIout.send([messageTypes.controllerChange | i, 123, 0]); // all sound off
|
| 457 |
+
}
|
| 458 |
+
this.MIDIout.send([messageTypes.reset]); // reset
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
/**
|
| 462 |
+
* @param messageType {WorkletSequencerMessageType}
|
| 463 |
+
* @param messageData {any}
|
| 464 |
+
* @private
|
| 465 |
+
*/
|
| 466 |
+
_sendMessage(messageType, messageData = undefined)
|
| 467 |
+
{
|
| 468 |
+
this.synth.post({
|
| 469 |
+
channelNumber: -1,
|
| 470 |
+
messageType: workletMessageType.sequencerSpecific,
|
| 471 |
+
messageData: {
|
| 472 |
+
messageType: messageType,
|
| 473 |
+
messageData: messageData
|
| 474 |
+
}
|
| 475 |
+
});
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/**
|
| 479 |
+
* Switch to the next song in the playlist
|
| 480 |
+
*/
|
| 481 |
+
nextSong()
|
| 482 |
+
{
|
| 483 |
+
this._sendMessage(WorkletSequencerMessageType.changeSong, [SongChangeType.forwards]);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
/**
|
| 487 |
+
* Switch to the previous song in the playlist
|
| 488 |
+
*/
|
| 489 |
+
previousSong()
|
| 490 |
+
{
|
| 491 |
+
this._sendMessage(WorkletSequencerMessageType.changeSong, [SongChangeType.backwards]);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/**
|
| 495 |
+
* Sets the song index in the playlist
|
| 496 |
+
* @param index
|
| 497 |
+
*/
|
| 498 |
+
setSongIndex(index)
|
| 499 |
+
{
|
| 500 |
+
const clamped = Math.max(Math.min(this.songsAmount - 1, index), 0);
|
| 501 |
+
this._sendMessage(WorkletSequencerMessageType.changeSong, [SongChangeType.index, clamped]);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/**
|
| 505 |
+
* @param type {Object<string, function>}
|
| 506 |
+
* @param params {any}
|
| 507 |
+
* @private
|
| 508 |
+
*/
|
| 509 |
+
_callEvents(type, params)
|
| 510 |
+
{
|
| 511 |
+
for (const key in type)
|
| 512 |
+
{
|
| 513 |
+
const callback = type[key];
|
| 514 |
+
try
|
| 515 |
+
{
|
| 516 |
+
callback(params);
|
| 517 |
+
}
|
| 518 |
+
catch (e)
|
| 519 |
+
{
|
| 520 |
+
SpessaSynthWarn(`Failed to execute callback for ${callback[0]}:`, e);
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
/**
|
| 526 |
+
* @param {WorkletSequencerReturnMessageType} messageType
|
| 527 |
+
* @param {any} messageData
|
| 528 |
+
* @private
|
| 529 |
+
*/
|
| 530 |
+
_handleMessage(messageType, messageData)
|
| 531 |
+
{
|
| 532 |
+
if (this.ignoreEvents)
|
| 533 |
+
{
|
| 534 |
+
return;
|
| 535 |
+
}
|
| 536 |
+
switch (messageType)
|
| 537 |
+
{
|
| 538 |
+
case WorkletSequencerReturnMessageType.midiEvent:
|
| 539 |
+
/**
|
| 540 |
+
* @type {number[]}
|
| 541 |
+
*/
|
| 542 |
+
let midiEventData = messageData;
|
| 543 |
+
if (this.MIDIout)
|
| 544 |
+
{
|
| 545 |
+
if (midiEventData[0] >= 0x80)
|
| 546 |
+
{
|
| 547 |
+
this.MIDIout.send(midiEventData);
|
| 548 |
+
return;
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
break;
|
| 552 |
+
|
| 553 |
+
case WorkletSequencerReturnMessageType.songChange:
|
| 554 |
+
this.songIndex = messageData[0];
|
| 555 |
+
const songChangeData = this.songListData[this.songIndex];
|
| 556 |
+
this.midiData = songChangeData;
|
| 557 |
+
this.hasDummyData = false;
|
| 558 |
+
this.absoluteStartTime = 0;
|
| 559 |
+
this.duration = this.midiData.duration;
|
| 560 |
+
this._callEvents(this.onSongChange, songChangeData);
|
| 561 |
+
// if is auto played, unpause
|
| 562 |
+
if (messageData[1] === true)
|
| 563 |
+
{
|
| 564 |
+
this.unpause();
|
| 565 |
+
}
|
| 566 |
+
break;
|
| 567 |
+
|
| 568 |
+
case WorkletSequencerReturnMessageType.timeChange:
|
| 569 |
+
// message data is absolute time
|
| 570 |
+
const time = this.synth.currentTime - messageData;
|
| 571 |
+
this._callEvents(this.onTimeChange, time);
|
| 572 |
+
this._recalculateStartTime(time);
|
| 573 |
+
if (this.paused && this._preservePlaybackState)
|
| 574 |
+
{
|
| 575 |
+
this.pausedTime = time;
|
| 576 |
+
}
|
| 577 |
+
else
|
| 578 |
+
{
|
| 579 |
+
this.unpause();
|
| 580 |
+
}
|
| 581 |
+
break;
|
| 582 |
+
|
| 583 |
+
case WorkletSequencerReturnMessageType.pause:
|
| 584 |
+
this.pausedTime = this.currentTime;
|
| 585 |
+
this.isFinished = messageData;
|
| 586 |
+
if (this.isFinished)
|
| 587 |
+
{
|
| 588 |
+
this._callEvents(this.onSongEnded, undefined);
|
| 589 |
+
}
|
| 590 |
+
break;
|
| 591 |
+
|
| 592 |
+
case WorkletSequencerReturnMessageType.midiError:
|
| 593 |
+
if (this.onError)
|
| 594 |
+
{
|
| 595 |
+
this.onError(messageData);
|
| 596 |
+
}
|
| 597 |
+
else
|
| 598 |
+
{
|
| 599 |
+
throw new Error("Sequencer error: " + messageData);
|
| 600 |
+
}
|
| 601 |
+
return;
|
| 602 |
+
|
| 603 |
+
case WorkletSequencerReturnMessageType.getMIDI:
|
| 604 |
+
if (this._getMIDIResolve)
|
| 605 |
+
{
|
| 606 |
+
this._getMIDIResolve(BasicMIDI.copyFrom(messageData));
|
| 607 |
+
}
|
| 608 |
+
break;
|
| 609 |
+
|
| 610 |
+
case WorkletSequencerReturnMessageType.metaEvent:
|
| 611 |
+
/**
|
| 612 |
+
* @type {MIDIMessage}
|
| 613 |
+
*/
|
| 614 |
+
const event = messageData[0];
|
| 615 |
+
switch (event.messageStatusByte)
|
| 616 |
+
{
|
| 617 |
+
case messageTypes.setTempo:
|
| 618 |
+
event.messageData.currentIndex = 0;
|
| 619 |
+
const bpm = 60000000 / readBytesAsUintBigEndian(event.messageData, 3);
|
| 620 |
+
event.messageData.currentIndex = 0;
|
| 621 |
+
this.currentTempo = Math.round(bpm * 100) / 100;
|
| 622 |
+
if (this.onTempoChange)
|
| 623 |
+
{
|
| 624 |
+
this._callEvents(this.onTempoChange, this.currentTempo);
|
| 625 |
+
}
|
| 626 |
+
break;
|
| 627 |
+
|
| 628 |
+
case messageTypes.text:
|
| 629 |
+
case messageTypes.lyric:
|
| 630 |
+
case messageTypes.copyright:
|
| 631 |
+
case messageTypes.trackName:
|
| 632 |
+
case messageTypes.marker:
|
| 633 |
+
case messageTypes.cuePoint:
|
| 634 |
+
case messageTypes.instrumentName:
|
| 635 |
+
case messageTypes.programName:
|
| 636 |
+
let lyricsIndex = -1;
|
| 637 |
+
if (event.messageStatusByte === messageTypes.lyric)
|
| 638 |
+
{
|
| 639 |
+
lyricsIndex = Math.min(
|
| 640 |
+
this.midiData.lyricsTicks.indexOf(event.ticks),
|
| 641 |
+
this.midiData.lyrics.length - 1
|
| 642 |
+
);
|
| 643 |
+
}
|
| 644 |
+
let sentStatus = event.messageStatusByte;
|
| 645 |
+
// if MIDI is a karaoke file, it uses the "text" event type or "lyrics" for lyrics (duh)
|
| 646 |
+
// why?
|
| 647 |
+
// because the MIDI standard is a messy pile of garbage,
|
| 648 |
+
// and it's not my fault that it's like this :(
|
| 649 |
+
// I'm just trying to make the best out of a bad situation.
|
| 650 |
+
// I'm sorry
|
| 651 |
+
// okay I should get back to work
|
| 652 |
+
// anyway,
|
| 653 |
+
// check for a karaoke file and change the status byte to "lyric"
|
| 654 |
+
// if it's a karaoke file
|
| 655 |
+
if (this.midiData.isKaraokeFile && (
|
| 656 |
+
event.messageStatusByte === messageTypes.text ||
|
| 657 |
+
event.messageStatusByte === messageTypes.lyric
|
| 658 |
+
))
|
| 659 |
+
{
|
| 660 |
+
lyricsIndex = Math.min(
|
| 661 |
+
this.midiData.lyricsTicks.indexOf(event.ticks),
|
| 662 |
+
this.midiData.lyricsTicks.length
|
| 663 |
+
);
|
| 664 |
+
sentStatus = messageTypes.lyric;
|
| 665 |
+
}
|
| 666 |
+
if (this.onTextEvent)
|
| 667 |
+
{
|
| 668 |
+
this.onTextEvent(event.messageData, sentStatus, lyricsIndex, event.ticks);
|
| 669 |
+
}
|
| 670 |
+
break;
|
| 671 |
+
}
|
| 672 |
+
this._callEvents(this.onMetaEvent, messageData);
|
| 673 |
+
break;
|
| 674 |
+
|
| 675 |
+
case WorkletSequencerReturnMessageType.loopCountChange:
|
| 676 |
+
this._loopsRemaining = messageData;
|
| 677 |
+
if (this._loopsRemaining === 0)
|
| 678 |
+
{
|
| 679 |
+
this._loop = false;
|
| 680 |
+
}
|
| 681 |
+
break;
|
| 682 |
+
|
| 683 |
+
case WorkletSequencerReturnMessageType.songListChange:
|
| 684 |
+
this.songListData = messageData;
|
| 685 |
+
break;
|
| 686 |
+
|
| 687 |
+
default:
|
| 688 |
+
break;
|
| 689 |
+
}
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
/**
|
| 693 |
+
* @param time
|
| 694 |
+
* @private
|
| 695 |
+
*/
|
| 696 |
+
_recalculateStartTime(time)
|
| 697 |
+
{
|
| 698 |
+
this.absoluteStartTime = this.synth.currentTime - time / this._playbackRate;
|
| 699 |
+
this.highResTimeOffset = (this.synth.currentTime - (performance.now() / 1000)) * this._playbackRate;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
/**
|
| 703 |
+
* @returns {Promise<MIDI>}
|
| 704 |
+
*/
|
| 705 |
+
async getMIDI()
|
| 706 |
+
{
|
| 707 |
+
return new Promise(resolve =>
|
| 708 |
+
{
|
| 709 |
+
this._getMIDIResolve = resolve;
|
| 710 |
+
this._sendMessage(WorkletSequencerMessageType.getMIDI, undefined);
|
| 711 |
+
});
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
/**
|
| 715 |
+
* Loads a new song list
|
| 716 |
+
* @param midiBuffers {MIDIFile[]} - the MIDI files to play
|
| 717 |
+
* @param autoPlay {boolean} - if true, the first sequence will automatically start playing
|
| 718 |
+
*/
|
| 719 |
+
loadNewSongList(midiBuffers, autoPlay = true)
|
| 720 |
+
{
|
| 721 |
+
this.pause();
|
| 722 |
+
// add some fake data
|
| 723 |
+
this.midiData = DUMMY_MIDI_DATA;
|
| 724 |
+
this.hasDummyData = true;
|
| 725 |
+
this.duration = 99999;
|
| 726 |
+
/**
|
| 727 |
+
* sanitize MIDIs
|
| 728 |
+
* @type {({binary: ArrayBuffer, altName: string}|BasicMIDI)[]}
|
| 729 |
+
*/
|
| 730 |
+
const sanitizedMidis = midiBuffers.map(m =>
|
| 731 |
+
{
|
| 732 |
+
if (m.binary !== undefined)
|
| 733 |
+
{
|
| 734 |
+
return m;
|
| 735 |
+
}
|
| 736 |
+
return BasicMIDI.copyFrom(m);
|
| 737 |
+
});
|
| 738 |
+
this._sendMessage(WorkletSequencerMessageType.loadNewSongList, [sanitizedMidis, autoPlay]);
|
| 739 |
+
this.songIndex = 0;
|
| 740 |
+
this.songsAmount = midiBuffers.length;
|
| 741 |
+
if (this.songsAmount > 1)
|
| 742 |
+
{
|
| 743 |
+
this.loop = false;
|
| 744 |
+
}
|
| 745 |
+
if (autoPlay === false)
|
| 746 |
+
{
|
| 747 |
+
this.pausedTime = this.currentTime;
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
/**
|
| 752 |
+
* @param output {MIDIOutput}
|
| 753 |
+
*/
|
| 754 |
+
connectMidiOutput(output)
|
| 755 |
+
{
|
| 756 |
+
this.resetMIDIOut();
|
| 757 |
+
this.MIDIout = output;
|
| 758 |
+
this._sendMessage(WorkletSequencerMessageType.changeMIDIMessageSending, output !== undefined);
|
| 759 |
+
this.currentTime -= 0.1;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
/**
|
| 763 |
+
* Pauses the playback
|
| 764 |
+
*/
|
| 765 |
+
pause()
|
| 766 |
+
{
|
| 767 |
+
if (this.paused)
|
| 768 |
+
{
|
| 769 |
+
SpessaSynthWarn("Already paused");
|
| 770 |
+
return;
|
| 771 |
+
}
|
| 772 |
+
this.pausedTime = this.currentTime;
|
| 773 |
+
this._sendMessage(WorkletSequencerMessageType.pause);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
unpause()
|
| 777 |
+
{
|
| 778 |
+
this.pausedTime = undefined;
|
| 779 |
+
this.isFinished = false;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
/**
|
| 783 |
+
* Starts the playback
|
| 784 |
+
* @param resetTime {boolean} If true, time is set to 0 s
|
| 785 |
+
*/
|
| 786 |
+
play(resetTime = false)
|
| 787 |
+
{
|
| 788 |
+
if (this.isFinished)
|
| 789 |
+
{
|
| 790 |
+
resetTime = true;
|
| 791 |
+
}
|
| 792 |
+
this._recalculateStartTime(this.pausedTime || 0);
|
| 793 |
+
this.unpause();
|
| 794 |
+
this._sendMessage(WorkletSequencerMessageType.play, resetTime);
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
/**
|
| 798 |
+
* Stops the playback
|
| 799 |
+
*/
|
| 800 |
+
stop()
|
| 801 |
+
{
|
| 802 |
+
this._sendMessage(WorkletSequencerMessageType.stop);
|
| 803 |
+
}
|
| 804 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/events.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
ALL_CHANNELS_OR_DIFFERENT_ACTION,
|
| 3 |
+
returnMessageType
|
| 4 |
+
} from "../../synthetizer/worklet_system/message_protocol/worklet_message.js";
|
| 5 |
+
import { SongChangeType, WorkletSequencerMessageType, WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 6 |
+
import { messageTypes, midiControllers } from "../../midi_parser/midi_message.js";
|
| 7 |
+
|
| 8 |
+
import { MIDI_CHANNEL_COUNT } from "../../synthetizer/synth_constants.js";
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* @param messageType {WorkletSequencerMessageType}
|
| 12 |
+
* @param messageData {any}
|
| 13 |
+
* @this {WorkletSequencer}
|
| 14 |
+
*/
|
| 15 |
+
export function processMessage(messageType, messageData)
|
| 16 |
+
{
|
| 17 |
+
switch (messageType)
|
| 18 |
+
{
|
| 19 |
+
default:
|
| 20 |
+
break;
|
| 21 |
+
|
| 22 |
+
case WorkletSequencerMessageType.loadNewSongList:
|
| 23 |
+
this.loadNewSongList(messageData[0], messageData[1]);
|
| 24 |
+
break;
|
| 25 |
+
|
| 26 |
+
case WorkletSequencerMessageType.pause:
|
| 27 |
+
this.pause();
|
| 28 |
+
break;
|
| 29 |
+
|
| 30 |
+
case WorkletSequencerMessageType.play:
|
| 31 |
+
this.play(messageData);
|
| 32 |
+
break;
|
| 33 |
+
|
| 34 |
+
case WorkletSequencerMessageType.stop:
|
| 35 |
+
this.stop();
|
| 36 |
+
break;
|
| 37 |
+
|
| 38 |
+
case WorkletSequencerMessageType.setTime:
|
| 39 |
+
this.currentTime = messageData;
|
| 40 |
+
break;
|
| 41 |
+
|
| 42 |
+
case WorkletSequencerMessageType.changeMIDIMessageSending:
|
| 43 |
+
this.sendMIDIMessages = messageData;
|
| 44 |
+
break;
|
| 45 |
+
|
| 46 |
+
case WorkletSequencerMessageType.setPlaybackRate:
|
| 47 |
+
this.playbackRate = messageData;
|
| 48 |
+
break;
|
| 49 |
+
|
| 50 |
+
case WorkletSequencerMessageType.setLoop:
|
| 51 |
+
const [loop, count] = messageData;
|
| 52 |
+
this.loop = loop;
|
| 53 |
+
if (count === ALL_CHANNELS_OR_DIFFERENT_ACTION)
|
| 54 |
+
{
|
| 55 |
+
this.loopCount = Infinity;
|
| 56 |
+
}
|
| 57 |
+
else
|
| 58 |
+
{
|
| 59 |
+
this.loopCount = count;
|
| 60 |
+
}
|
| 61 |
+
break;
|
| 62 |
+
|
| 63 |
+
case WorkletSequencerMessageType.changeSong:
|
| 64 |
+
switch (messageData[0])
|
| 65 |
+
{
|
| 66 |
+
case SongChangeType.forwards:
|
| 67 |
+
this.nextSong();
|
| 68 |
+
break;
|
| 69 |
+
|
| 70 |
+
case SongChangeType.backwards:
|
| 71 |
+
this.previousSong();
|
| 72 |
+
break;
|
| 73 |
+
|
| 74 |
+
case SongChangeType.shuffleOff:
|
| 75 |
+
this.shuffleMode = false;
|
| 76 |
+
this.songIndex = this.shuffledSongIndexes[this.songIndex];
|
| 77 |
+
break;
|
| 78 |
+
|
| 79 |
+
case SongChangeType.shuffleOn:
|
| 80 |
+
this.shuffleMode = true;
|
| 81 |
+
this.shuffleSongIndexes();
|
| 82 |
+
this.songIndex = 0;
|
| 83 |
+
this.loadCurrentSong();
|
| 84 |
+
break;
|
| 85 |
+
|
| 86 |
+
case SongChangeType.index:
|
| 87 |
+
this.songIndex = messageData[1];
|
| 88 |
+
this.loadCurrentSong();
|
| 89 |
+
break;
|
| 90 |
+
}
|
| 91 |
+
break;
|
| 92 |
+
|
| 93 |
+
case WorkletSequencerMessageType.getMIDI:
|
| 94 |
+
this.post(WorkletSequencerReturnMessageType.getMIDI, this.midiData);
|
| 95 |
+
break;
|
| 96 |
+
|
| 97 |
+
case WorkletSequencerMessageType.setSkipToFirstNote:
|
| 98 |
+
this.skipToFirstNoteOn = messageData;
|
| 99 |
+
break;
|
| 100 |
+
|
| 101 |
+
case WorkletSequencerMessageType.setPreservePlaybackState:
|
| 102 |
+
this.preservePlaybackState = messageData;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
*
|
| 108 |
+
* @param messageType {WorkletSequencerReturnMessageType}
|
| 109 |
+
* @param messageData {any}
|
| 110 |
+
* @this {WorkletSequencer}
|
| 111 |
+
*/
|
| 112 |
+
export function post(messageType, messageData = undefined)
|
| 113 |
+
{
|
| 114 |
+
if (!this.synth.enableEventSystem)
|
| 115 |
+
{
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
this.synth.post({
|
| 119 |
+
messageType: returnMessageType.sequencerSpecific,
|
| 120 |
+
messageData: {
|
| 121 |
+
messageType: messageType,
|
| 122 |
+
messageData: messageData
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* @param message {number[]}
|
| 129 |
+
* @this {WorkletSequencer}
|
| 130 |
+
*/
|
| 131 |
+
export function sendMIDIMessage(message)
|
| 132 |
+
{
|
| 133 |
+
this.post(WorkletSequencerReturnMessageType.midiEvent, message);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* @this {WorkletSequencer}
|
| 138 |
+
* @param channel {number}
|
| 139 |
+
* @param type {number}
|
| 140 |
+
* @param value {number}
|
| 141 |
+
*/
|
| 142 |
+
export function sendMIDICC(channel, type, value)
|
| 143 |
+
{
|
| 144 |
+
channel %= 16;
|
| 145 |
+
if (!this.sendMIDIMessages)
|
| 146 |
+
{
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
this.sendMIDIMessage([messageTypes.controllerChange | channel, type, value]);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* @this {WorkletSequencer}
|
| 154 |
+
* @param channel {number}
|
| 155 |
+
* @param program {number}
|
| 156 |
+
*/
|
| 157 |
+
export function sendMIDIProgramChange(channel, program)
|
| 158 |
+
{
|
| 159 |
+
channel %= 16;
|
| 160 |
+
if (!this.sendMIDIMessages)
|
| 161 |
+
{
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
this.sendMIDIMessage([messageTypes.programChange | channel, program]);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* Sets the pitch of the given channel
|
| 169 |
+
* @this {WorkletSequencer}
|
| 170 |
+
* @param channel {number} usually 0-15: the channel to change pitch
|
| 171 |
+
* @param MSB {number} SECOND byte of the MIDI pitchWheel message
|
| 172 |
+
* @param LSB {number} FIRST byte of the MIDI pitchWheel message
|
| 173 |
+
*/
|
| 174 |
+
export function sendMIDIPitchWheel(channel, MSB, LSB)
|
| 175 |
+
{
|
| 176 |
+
channel %= 16;
|
| 177 |
+
if (!this.sendMIDIMessages)
|
| 178 |
+
{
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
this.sendMIDIMessage([messageTypes.pitchBend | channel, LSB, MSB]);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* @this {WorkletSequencer}
|
| 186 |
+
*/
|
| 187 |
+
export function sendMIDIReset()
|
| 188 |
+
{
|
| 189 |
+
if (!this.sendMIDIMessages)
|
| 190 |
+
{
|
| 191 |
+
return;
|
| 192 |
+
}
|
| 193 |
+
this.sendMIDIMessage([messageTypes.reset]);
|
| 194 |
+
for (let ch = 0; ch < MIDI_CHANNEL_COUNT; ch++)
|
| 195 |
+
{
|
| 196 |
+
this.sendMIDIMessage([messageTypes.controllerChange | ch, midiControllers.allSoundOff, 0]);
|
| 197 |
+
this.sendMIDIMessage([messageTypes.controllerChange | ch, midiControllers.resetAllControllers, 0]);
|
| 198 |
+
}
|
| 199 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/play.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getEvent, messageTypes, midiControllers } from "../../midi_parser/midi_message.js";
|
| 2 |
+
import { WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 3 |
+
import { resetArray } from "../../synthetizer/worklet_system/worklet_utilities/controller_tables.js";
|
| 4 |
+
import {
|
| 5 |
+
nonResetableCCs
|
| 6 |
+
} from "../../synthetizer/worklet_system/worklet_methods/controller_control/reset_controllers.js";
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
// an array with preset default values
|
| 10 |
+
const defaultControllerArray = resetArray.slice(0, 128);
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* plays from start to the target time, excluding note messages (to get the synth to the correct state)
|
| 14 |
+
* @private
|
| 15 |
+
* @param time {number} in seconds
|
| 16 |
+
* @param ticks {number} optional MIDI ticks, when given is used instead of time
|
| 17 |
+
* @returns {boolean} true if the midi file is not finished
|
| 18 |
+
* @this {WorkletSequencer}
|
| 19 |
+
*/
|
| 20 |
+
export function _playTo(time, ticks = undefined)
|
| 21 |
+
{
|
| 22 |
+
this.oneTickToSeconds = 60 / (120 * this.midiData.timeDivision);
|
| 23 |
+
// reset
|
| 24 |
+
this.synth.resetAllControllers();
|
| 25 |
+
this.sendMIDIReset();
|
| 26 |
+
this._resetTimers();
|
| 27 |
+
|
| 28 |
+
const channelsToSave = this.synth.workletProcessorChannels.length;
|
| 29 |
+
/**
|
| 30 |
+
* save pitch bends here and send them only after
|
| 31 |
+
* @type {number[]}
|
| 32 |
+
*/
|
| 33 |
+
const pitchBends = Array(channelsToSave).fill(8192);
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Save programs here and send them only after
|
| 37 |
+
* @type {{program: number, bank: number, actualBank: number}[]}
|
| 38 |
+
*/
|
| 39 |
+
const programs = [];
|
| 40 |
+
for (let i = 0; i < channelsToSave; i++)
|
| 41 |
+
{
|
| 42 |
+
programs.push({
|
| 43 |
+
program: -1,
|
| 44 |
+
bank: 0,
|
| 45 |
+
actualBank: 0
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const isCCNonSkippable = controllerNumber => (
|
| 50 |
+
controllerNumber === midiControllers.dataDecrement ||
|
| 51 |
+
controllerNumber === midiControllers.dataIncrement ||
|
| 52 |
+
controllerNumber === midiControllers.dataEntryMsb ||
|
| 53 |
+
controllerNumber === midiControllers.dataDecrement ||
|
| 54 |
+
controllerNumber === midiControllers.lsbForControl6DataEntry ||
|
| 55 |
+
controllerNumber === midiControllers.RPNLsb ||
|
| 56 |
+
controllerNumber === midiControllers.RPNMsb ||
|
| 57 |
+
controllerNumber === midiControllers.NRPNLsb ||
|
| 58 |
+
controllerNumber === midiControllers.NRPNMsb ||
|
| 59 |
+
controllerNumber === midiControllers.bankSelect ||
|
| 60 |
+
controllerNumber === midiControllers.lsbForControl0BankSelect ||
|
| 61 |
+
controllerNumber === midiControllers.resetAllControllers
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Save controllers here and send them only after
|
| 66 |
+
* @type {number[][]}
|
| 67 |
+
*/
|
| 68 |
+
const savedControllers = [];
|
| 69 |
+
for (let i = 0; i < channelsToSave; i++)
|
| 70 |
+
{
|
| 71 |
+
savedControllers.push(Array.from(defaultControllerArray));
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* RP-15 compliant reset
|
| 76 |
+
* https://amei.or.jp/midistandardcommittee/Recommended_Practice/e/rp15.pdf
|
| 77 |
+
* @param chan {number}
|
| 78 |
+
*/
|
| 79 |
+
function resetAllControlllers(chan)
|
| 80 |
+
{
|
| 81 |
+
// reset pitch bend
|
| 82 |
+
pitchBends[chan] = 8192;
|
| 83 |
+
if (savedControllers?.[chan] === undefined)
|
| 84 |
+
{
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
for (let i = 0; i < defaultControllerArray.length; i++)
|
| 88 |
+
{
|
| 89 |
+
if (!nonResetableCCs.has(i))
|
| 90 |
+
{
|
| 91 |
+
savedControllers[chan][i] = defaultControllerArray[i];
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
while (true)
|
| 97 |
+
{
|
| 98 |
+
// find the next event
|
| 99 |
+
let trackIndex = this._findFirstEventIndex();
|
| 100 |
+
let event = this.tracks[trackIndex][this.eventIndex[trackIndex]];
|
| 101 |
+
if (ticks !== undefined)
|
| 102 |
+
{
|
| 103 |
+
if (event.ticks >= ticks)
|
| 104 |
+
{
|
| 105 |
+
break;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
else
|
| 109 |
+
{
|
| 110 |
+
if (this.playedTime >= time)
|
| 111 |
+
{
|
| 112 |
+
break;
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// skip note ons
|
| 117 |
+
const info = getEvent(event.messageStatusByte);
|
| 118 |
+
// Keep in mind midi ports to determine the channel!
|
| 119 |
+
const channel = info.channel + (this.midiPortChannelOffsets[this.midiPorts[trackIndex]] || 0);
|
| 120 |
+
switch (info.status)
|
| 121 |
+
{
|
| 122 |
+
// skip note messages
|
| 123 |
+
case messageTypes.noteOn:
|
| 124 |
+
// track portamento control as last note
|
| 125 |
+
if (savedControllers[channel] === undefined)
|
| 126 |
+
{
|
| 127 |
+
savedControllers[channel] = Array.from(defaultControllerArray);
|
| 128 |
+
}
|
| 129 |
+
savedControllers[channel][midiControllers.portamentoControl] = event.messageData[0];
|
| 130 |
+
break;
|
| 131 |
+
|
| 132 |
+
case messageTypes.noteOff:
|
| 133 |
+
break;
|
| 134 |
+
|
| 135 |
+
// skip pitch bend
|
| 136 |
+
case messageTypes.pitchBend:
|
| 137 |
+
pitchBends[channel] = event.messageData[1] << 7 | event.messageData[0];
|
| 138 |
+
break;
|
| 139 |
+
|
| 140 |
+
case messageTypes.programChange:
|
| 141 |
+
// empty tracks cannot program change
|
| 142 |
+
if (this.midiData.isMultiPort && this.midiData.usedChannelsOnTrack[trackIndex].size === 0)
|
| 143 |
+
{
|
| 144 |
+
break;
|
| 145 |
+
}
|
| 146 |
+
const p = programs[channel];
|
| 147 |
+
p.program = event.messageData[0];
|
| 148 |
+
p.actualBank = p.bank;
|
| 149 |
+
break;
|
| 150 |
+
|
| 151 |
+
case messageTypes.controllerChange:
|
| 152 |
+
// empty tracks cannot controller change
|
| 153 |
+
if (this.midiData.isMultiPort && this.midiData.usedChannelsOnTrack[trackIndex].size === 0)
|
| 154 |
+
{
|
| 155 |
+
break;
|
| 156 |
+
}
|
| 157 |
+
// do not skip data entries
|
| 158 |
+
const controllerNumber = event.messageData[0];
|
| 159 |
+
if (isCCNonSkippable(controllerNumber))
|
| 160 |
+
{
|
| 161 |
+
let ccV = event.messageData[1];
|
| 162 |
+
if (controllerNumber === midiControllers.bankSelect)
|
| 163 |
+
{
|
| 164 |
+
// add the bank to be saved
|
| 165 |
+
programs[channel].bank = ccV;
|
| 166 |
+
break;
|
| 167 |
+
}
|
| 168 |
+
else if (controllerNumber === midiControllers.resetAllControllers)
|
| 169 |
+
{
|
| 170 |
+
resetAllControlllers(channel);
|
| 171 |
+
}
|
| 172 |
+
if (this.sendMIDIMessages)
|
| 173 |
+
{
|
| 174 |
+
this.sendMIDICC(channel, controllerNumber, ccV);
|
| 175 |
+
}
|
| 176 |
+
else
|
| 177 |
+
{
|
| 178 |
+
this.synth.controllerChange(channel, controllerNumber, ccV);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
else
|
| 182 |
+
{
|
| 183 |
+
if (savedControllers[channel] === undefined)
|
| 184 |
+
{
|
| 185 |
+
savedControllers[channel] = Array.from(defaultControllerArray);
|
| 186 |
+
}
|
| 187 |
+
savedControllers[channel][controllerNumber] = event.messageData[1];
|
| 188 |
+
}
|
| 189 |
+
break;
|
| 190 |
+
|
| 191 |
+
default:
|
| 192 |
+
this._processEvent(event, trackIndex);
|
| 193 |
+
break;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
this.eventIndex[trackIndex]++;
|
| 197 |
+
// find the next event
|
| 198 |
+
trackIndex = this._findFirstEventIndex();
|
| 199 |
+
let nextEvent = this.tracks[trackIndex][this.eventIndex[trackIndex]];
|
| 200 |
+
if (nextEvent === undefined)
|
| 201 |
+
{
|
| 202 |
+
this.stop();
|
| 203 |
+
return false;
|
| 204 |
+
}
|
| 205 |
+
this.playedTime += this.oneTickToSeconds * (nextEvent.ticks - event.ticks);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// restoring saved controllers
|
| 209 |
+
if (this.sendMIDIMessages)
|
| 210 |
+
{
|
| 211 |
+
for (let channelNumber = 0; channelNumber < channelsToSave; channelNumber++)
|
| 212 |
+
{
|
| 213 |
+
// restore pitch bends
|
| 214 |
+
if (pitchBends[channelNumber] !== undefined)
|
| 215 |
+
{
|
| 216 |
+
this.sendMIDIPitchWheel(
|
| 217 |
+
channelNumber,
|
| 218 |
+
pitchBends[channelNumber] >> 7,
|
| 219 |
+
pitchBends[channelNumber] & 0x7F
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
if (savedControllers[channelNumber] !== undefined)
|
| 223 |
+
{
|
| 224 |
+
// every controller that has changed
|
| 225 |
+
savedControllers[channelNumber].forEach((value, index) =>
|
| 226 |
+
{
|
| 227 |
+
if (value !== defaultControllerArray[index] && !isCCNonSkippable(
|
| 228 |
+
index))
|
| 229 |
+
{
|
| 230 |
+
this.sendMIDICC(channelNumber, index, value);
|
| 231 |
+
}
|
| 232 |
+
});
|
| 233 |
+
}
|
| 234 |
+
// restore programs
|
| 235 |
+
if (programs[channelNumber].program >= 0 && programs[channelNumber].actualBank >= 0)
|
| 236 |
+
{
|
| 237 |
+
const bank = programs[channelNumber].actualBank;
|
| 238 |
+
this.sendMIDICC(channelNumber, midiControllers.bankSelect, bank);
|
| 239 |
+
this.sendMIDIProgramChange(channelNumber, programs[channelNumber].program);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
else
|
| 244 |
+
{
|
| 245 |
+
// for all synth channels
|
| 246 |
+
for (let channelNumber = 0; channelNumber < channelsToSave; channelNumber++)
|
| 247 |
+
{
|
| 248 |
+
// restore pitch bends
|
| 249 |
+
if (pitchBends[channelNumber] !== undefined)
|
| 250 |
+
{
|
| 251 |
+
this.synth.pitchWheel(channelNumber, pitchBends[channelNumber] >> 7, pitchBends[channelNumber] & 0x7F);
|
| 252 |
+
}
|
| 253 |
+
if (savedControllers[channelNumber] !== undefined)
|
| 254 |
+
{
|
| 255 |
+
// every controller that has changed
|
| 256 |
+
savedControllers[channelNumber].forEach((value, index) =>
|
| 257 |
+
{
|
| 258 |
+
if (value !== defaultControllerArray[index] && !isCCNonSkippable(
|
| 259 |
+
index))
|
| 260 |
+
{
|
| 261 |
+
this.synth.controllerChange(
|
| 262 |
+
channelNumber,
|
| 263 |
+
index,
|
| 264 |
+
value
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
});
|
| 268 |
+
}
|
| 269 |
+
// restore programs
|
| 270 |
+
if (programs[channelNumber].program >= 0 && programs[channelNumber].actualBank >= 0)
|
| 271 |
+
{
|
| 272 |
+
const bank = programs[channelNumber].actualBank;
|
| 273 |
+
this.synth.controllerChange(channelNumber, midiControllers.bankSelect, bank);
|
| 274 |
+
this.synth.programChange(channelNumber, programs[channelNumber].program);
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
return true;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* Starts the playback
|
| 283 |
+
* @param resetTime {boolean} If true, time is set to 0 s
|
| 284 |
+
* @this {WorkletSequencer}
|
| 285 |
+
*/
|
| 286 |
+
export function play(resetTime = false)
|
| 287 |
+
{
|
| 288 |
+
if (this.midiData === undefined)
|
| 289 |
+
{
|
| 290 |
+
return;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// reset the time if necessary
|
| 294 |
+
if (resetTime)
|
| 295 |
+
{
|
| 296 |
+
this.pausedTime = undefined;
|
| 297 |
+
this.currentTime = 0;
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
if (this.currentTime >= this.duration)
|
| 302 |
+
{
|
| 303 |
+
this.pausedTime = undefined;
|
| 304 |
+
this.currentTime = 0;
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// unpause if paused
|
| 309 |
+
if (this.paused)
|
| 310 |
+
{
|
| 311 |
+
// adjust the start time
|
| 312 |
+
this._recalculateStartTime(this.pausedTime);
|
| 313 |
+
this.pausedTime = undefined;
|
| 314 |
+
}
|
| 315 |
+
if (!this.sendMIDIMessages)
|
| 316 |
+
{
|
| 317 |
+
this.playingNotes.forEach(n =>
|
| 318 |
+
{
|
| 319 |
+
this.synth.noteOn(n.channel, n.midiNote, n.velocity);
|
| 320 |
+
});
|
| 321 |
+
}
|
| 322 |
+
this.setProcessHandler();
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
/**
|
| 326 |
+
* @this {WorkletSequencer}
|
| 327 |
+
* @param ticks {number}
|
| 328 |
+
*/
|
| 329 |
+
export function setTimeTicks(ticks)
|
| 330 |
+
{
|
| 331 |
+
this.stop();
|
| 332 |
+
this.playingNotes = [];
|
| 333 |
+
this.pausedTime = undefined;
|
| 334 |
+
this.post(
|
| 335 |
+
WorkletSequencerReturnMessageType.timeChange,
|
| 336 |
+
currentTime - this.midiData.MIDIticksToSeconds(ticks)
|
| 337 |
+
);
|
| 338 |
+
const isNotFinished = this._playTo(0, ticks);
|
| 339 |
+
this._recalculateStartTime(this.playedTime);
|
| 340 |
+
if (!isNotFinished)
|
| 341 |
+
{
|
| 342 |
+
return;
|
| 343 |
+
}
|
| 344 |
+
this.play();
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* @param time
|
| 349 |
+
* @private
|
| 350 |
+
* @this {WorkletSequencer}
|
| 351 |
+
*/
|
| 352 |
+
export function _recalculateStartTime(time)
|
| 353 |
+
{
|
| 354 |
+
this.absoluteStartTime = currentTime - time / this._playbackRate;
|
| 355 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/process_event.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getEvent, messageTypes } from "../../midi_parser/midi_message.js";
|
| 2 |
+
import { WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 3 |
+
import { consoleColors } from "../../utils/other.js";
|
| 4 |
+
import { SpessaSynthWarn } from "../../utils/loggin.js";
|
| 5 |
+
import { readBytesAsUintBigEndian } from "../../utils/byte_functions/big_endian.js";
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Processes a single event
|
| 9 |
+
* @param event {MIDIMessage}
|
| 10 |
+
* @param trackIndex {number}
|
| 11 |
+
* @this {WorkletSequencer}
|
| 12 |
+
* @private
|
| 13 |
+
*/
|
| 14 |
+
export function _processEvent(event, trackIndex)
|
| 15 |
+
{
|
| 16 |
+
if (this.sendMIDIMessages)
|
| 17 |
+
{
|
| 18 |
+
if (event.messageStatusByte >= 0x80)
|
| 19 |
+
{
|
| 20 |
+
this.sendMIDIMessage([event.messageStatusByte, ...event.messageData]);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
const statusByteData = getEvent(event.messageStatusByte);
|
| 25 |
+
const offset = this.midiPortChannelOffsets[this.midiPorts[trackIndex]] || 0;
|
| 26 |
+
statusByteData.channel += offset;
|
| 27 |
+
// process the event
|
| 28 |
+
switch (statusByteData.status)
|
| 29 |
+
{
|
| 30 |
+
case messageTypes.noteOn:
|
| 31 |
+
const velocity = event.messageData[1];
|
| 32 |
+
if (velocity > 0)
|
| 33 |
+
{
|
| 34 |
+
this.synth.noteOn(statusByteData.channel, event.messageData[0], velocity);
|
| 35 |
+
this.playingNotes.push({
|
| 36 |
+
midiNote: event.messageData[0],
|
| 37 |
+
channel: statusByteData.channel,
|
| 38 |
+
velocity: velocity
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
else
|
| 42 |
+
{
|
| 43 |
+
this.synth.noteOff(statusByteData.channel, event.messageData[0]);
|
| 44 |
+
const toDelete = this.playingNotes.findIndex(n =>
|
| 45 |
+
n.midiNote === event.messageData[0] && n.channel === statusByteData.channel);
|
| 46 |
+
if (toDelete !== -1)
|
| 47 |
+
{
|
| 48 |
+
this.playingNotes.splice(toDelete, 1);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
break;
|
| 52 |
+
|
| 53 |
+
case messageTypes.noteOff:
|
| 54 |
+
this.synth.noteOff(statusByteData.channel, event.messageData[0]);
|
| 55 |
+
const toDelete = this.playingNotes.findIndex(n =>
|
| 56 |
+
n.midiNote === event.messageData[0] && n.channel === statusByteData.channel);
|
| 57 |
+
if (toDelete !== -1)
|
| 58 |
+
{
|
| 59 |
+
this.playingNotes.splice(toDelete, 1);
|
| 60 |
+
}
|
| 61 |
+
break;
|
| 62 |
+
|
| 63 |
+
case messageTypes.pitchBend:
|
| 64 |
+
this.synth.pitchWheel(statusByteData.channel, event.messageData[1], event.messageData[0]);
|
| 65 |
+
break;
|
| 66 |
+
|
| 67 |
+
case messageTypes.controllerChange:
|
| 68 |
+
// empty tracks cannot cc change
|
| 69 |
+
if (this.midiData.isMultiPort && this.midiData.usedChannelsOnTrack[trackIndex].size === 0)
|
| 70 |
+
{
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
this.synth.controllerChange(statusByteData.channel, event.messageData[0], event.messageData[1]);
|
| 74 |
+
break;
|
| 75 |
+
|
| 76 |
+
case messageTypes.programChange:
|
| 77 |
+
// empty tracks cannot program change
|
| 78 |
+
if (this.midiData.isMultiPort && this.midiData.usedChannelsOnTrack[trackIndex].size === 0)
|
| 79 |
+
{
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
this.synth.programChange(statusByteData.channel, event.messageData[0]);
|
| 83 |
+
break;
|
| 84 |
+
|
| 85 |
+
case messageTypes.polyPressure:
|
| 86 |
+
this.synth.polyPressure(statusByteData.channel, event.messageData[0], event.messageData[1]);
|
| 87 |
+
break;
|
| 88 |
+
|
| 89 |
+
case messageTypes.channelPressure:
|
| 90 |
+
this.synth.channelPressure(statusByteData.channel, event.messageData[0]);
|
| 91 |
+
break;
|
| 92 |
+
|
| 93 |
+
case messageTypes.systemExclusive:
|
| 94 |
+
this.synth.systemExclusive(event.messageData, offset);
|
| 95 |
+
break;
|
| 96 |
+
|
| 97 |
+
case messageTypes.setTempo:
|
| 98 |
+
event.messageData.currentIndex = 0;
|
| 99 |
+
let tempoBPM = 60000000 / readBytesAsUintBigEndian(event.messageData, 3);
|
| 100 |
+
this.oneTickToSeconds = 60 / (tempoBPM * this.midiData.timeDivision);
|
| 101 |
+
if (this.oneTickToSeconds === 0)
|
| 102 |
+
{
|
| 103 |
+
this.oneTickToSeconds = 60 / (120 * this.midiData.timeDivision);
|
| 104 |
+
SpessaSynthWarn("invalid tempo! falling back to 120 BPM");
|
| 105 |
+
tempoBPM = 120;
|
| 106 |
+
}
|
| 107 |
+
break;
|
| 108 |
+
|
| 109 |
+
// recognized but ignored
|
| 110 |
+
case messageTypes.timeSignature:
|
| 111 |
+
case messageTypes.endOfTrack:
|
| 112 |
+
case messageTypes.midiChannelPrefix:
|
| 113 |
+
case messageTypes.songPosition:
|
| 114 |
+
case messageTypes.activeSensing:
|
| 115 |
+
case messageTypes.keySignature:
|
| 116 |
+
case messageTypes.sequenceNumber:
|
| 117 |
+
case messageTypes.sequenceSpecific:
|
| 118 |
+
case messageTypes.text:
|
| 119 |
+
case messageTypes.lyric:
|
| 120 |
+
case messageTypes.copyright:
|
| 121 |
+
case messageTypes.trackName:
|
| 122 |
+
case messageTypes.marker:
|
| 123 |
+
case messageTypes.cuePoint:
|
| 124 |
+
case messageTypes.instrumentName:
|
| 125 |
+
case messageTypes.programName:
|
| 126 |
+
break;
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
case messageTypes.midiPort:
|
| 130 |
+
this.assignMIDIPort(trackIndex, event.messageData[0]);
|
| 131 |
+
break;
|
| 132 |
+
|
| 133 |
+
case messageTypes.reset:
|
| 134 |
+
this.synth.stopAllChannels();
|
| 135 |
+
this.synth.resetAllControllers();
|
| 136 |
+
break;
|
| 137 |
+
|
| 138 |
+
default:
|
| 139 |
+
SpessaSynthWarn(
|
| 140 |
+
`%cUnrecognized Event: %c${event.messageStatusByte}%c status byte: %c${Object.keys(
|
| 141 |
+
messageTypes).find(k => messageTypes[k] === statusByteData.status)}`,
|
| 142 |
+
consoleColors.warn,
|
| 143 |
+
consoleColors.unrecognized,
|
| 144 |
+
consoleColors.warn,
|
| 145 |
+
consoleColors.value
|
| 146 |
+
);
|
| 147 |
+
break;
|
| 148 |
+
}
|
| 149 |
+
if (statusByteData.status >= 0 && statusByteData.status < 0x80)
|
| 150 |
+
{
|
| 151 |
+
this.post(
|
| 152 |
+
WorkletSequencerReturnMessageType.metaEvent,
|
| 153 |
+
[event, trackIndex]
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Adds 16 channels to the synth
|
| 160 |
+
* @this {WorkletSequencer}
|
| 161 |
+
* @private
|
| 162 |
+
*/
|
| 163 |
+
export function _addNewMidiPort()
|
| 164 |
+
{
|
| 165 |
+
for (let i = 0; i < 16; i++)
|
| 166 |
+
{
|
| 167 |
+
this.synth.createWorkletChannel(true);
|
| 168 |
+
}
|
| 169 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/process_tick.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Processes a single tick
|
| 5 |
+
* @this {WorkletSequencer}
|
| 6 |
+
*/
|
| 7 |
+
export function processTick()
|
| 8 |
+
{
|
| 9 |
+
if (!this.isActive)
|
| 10 |
+
{
|
| 11 |
+
return;
|
| 12 |
+
}
|
| 13 |
+
let current = this.currentTime;
|
| 14 |
+
while (this.playedTime < current)
|
| 15 |
+
{
|
| 16 |
+
// find the next event
|
| 17 |
+
let trackIndex = this._findFirstEventIndex();
|
| 18 |
+
let event = this.tracks[trackIndex][this.eventIndex[trackIndex]];
|
| 19 |
+
this._processEvent(event, trackIndex);
|
| 20 |
+
|
| 21 |
+
this.eventIndex[trackIndex]++;
|
| 22 |
+
|
| 23 |
+
// find the next event
|
| 24 |
+
trackIndex = this._findFirstEventIndex();
|
| 25 |
+
if (this.tracks[trackIndex].length <= this.eventIndex[trackIndex])
|
| 26 |
+
{
|
| 27 |
+
// the song has ended
|
| 28 |
+
if (this.loop)
|
| 29 |
+
{
|
| 30 |
+
this.setTimeTicks(this.midiData.loop.start);
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
this.eventIndex[trackIndex]--;
|
| 34 |
+
this.pause(true);
|
| 35 |
+
if (this.songs.length > 1)
|
| 36 |
+
{
|
| 37 |
+
this.nextSong();
|
| 38 |
+
}
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
let eventNext = this.tracks[trackIndex][this.eventIndex[trackIndex]];
|
| 42 |
+
this.playedTime += this.oneTickToSeconds * (eventNext.ticks - event.ticks);
|
| 43 |
+
|
| 44 |
+
const canLoop = this.loop && (this.loopCount > 0 || this.loopCount === -1);
|
| 45 |
+
|
| 46 |
+
// if we reached loop.end
|
| 47 |
+
if ((this.midiData.loop.end <= event.ticks) && canLoop)
|
| 48 |
+
{
|
| 49 |
+
// loop
|
| 50 |
+
if (this.loopCount !== Infinity)
|
| 51 |
+
{
|
| 52 |
+
this.loopCount--;
|
| 53 |
+
this.post(WorkletSequencerReturnMessageType.loopCountChange, this.loopCount);
|
| 54 |
+
}
|
| 55 |
+
this.setTimeTicks(this.midiData.loop.start);
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
// if the song has ended
|
| 59 |
+
else if (current >= this.duration)
|
| 60 |
+
{
|
| 61 |
+
if (canLoop)
|
| 62 |
+
{
|
| 63 |
+
// loop
|
| 64 |
+
if (this.loopCount !== Infinity)
|
| 65 |
+
{
|
| 66 |
+
this.loopCount--;
|
| 67 |
+
this.post(WorkletSequencerReturnMessageType.loopCountChange, this.loopCount);
|
| 68 |
+
}
|
| 69 |
+
this.setTimeTicks(this.midiData.loop.start);
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
// stop the playback
|
| 73 |
+
this.eventIndex[trackIndex]--;
|
| 74 |
+
this.pause(true);
|
| 75 |
+
if (this.songs.length > 1)
|
| 76 |
+
{
|
| 77 |
+
this.nextSong();
|
| 78 |
+
}
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* @returns {number} the index of the first to the current played time
|
| 87 |
+
* @this {WorkletSequencer}
|
| 88 |
+
*/
|
| 89 |
+
export function _findFirstEventIndex()
|
| 90 |
+
{
|
| 91 |
+
let index = 0;
|
| 92 |
+
let ticks = Infinity;
|
| 93 |
+
this.tracks.forEach((track, i) =>
|
| 94 |
+
{
|
| 95 |
+
if (this.eventIndex[i] >= track.length)
|
| 96 |
+
{
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
if (track[this.eventIndex[i]].ticks < ticks)
|
| 100 |
+
{
|
| 101 |
+
index = i;
|
| 102 |
+
ticks = track[this.eventIndex[i]].ticks;
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
return index;
|
| 106 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/sequencer_message.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SongChangeType = {
|
| 2 |
+
backwards: 0, // no additional data
|
| 3 |
+
forwards: 1, // no additional data
|
| 4 |
+
shuffleOn: 2, // no additional data
|
| 5 |
+
shuffleOff: 3, // no additional data
|
| 6 |
+
index: 4 // songIndex<number>
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* @enum {number}
|
| 11 |
+
* @property {number} loadNewSongList - 0 -> [...song<MIDI>]
|
| 12 |
+
* @property {number} pause - 1 -> isFinished<boolean>
|
| 13 |
+
* @property {number} stop - 2 -> (no data)
|
| 14 |
+
* @property {number} play - 3 -> resetTime<boolean>
|
| 15 |
+
* @property {number} setTime - 4 -> time<number>
|
| 16 |
+
* @property {number} changeMIDIMessageSending - 5 -> sendMIDIMessages<boolean>
|
| 17 |
+
* @property {number} setPlaybackRate - 6 -> playbackRate<number>
|
| 18 |
+
* @property {number} setLoop - 7 -> [loop<boolean>, count<number>]
|
| 19 |
+
* @property {number} changeSong - 8 -> [changeType<SongChangeType>, data<number>]
|
| 20 |
+
* @property {number} getMIDI - 9 -> (no data)
|
| 21 |
+
* @property {number} setSkipToFirstNote -10 -> skipToFirstNoteOn<boolean>
|
| 22 |
+
* @property {number} setPreservePlaybackState -11 -> preservePlaybackState<boolean>
|
| 23 |
+
*/
|
| 24 |
+
export const WorkletSequencerMessageType = {
|
| 25 |
+
loadNewSongList: 0,
|
| 26 |
+
pause: 1,
|
| 27 |
+
stop: 2,
|
| 28 |
+
play: 3,
|
| 29 |
+
setTime: 4,
|
| 30 |
+
changeMIDIMessageSending: 5,
|
| 31 |
+
setPlaybackRate: 6,
|
| 32 |
+
setLoop: 7,
|
| 33 |
+
changeSong: 8,
|
| 34 |
+
getMIDI: 9,
|
| 35 |
+
setSkipToFirstNote: 10,
|
| 36 |
+
setPreservePlaybackState: 11
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
*
|
| 41 |
+
* @enum {number}
|
| 42 |
+
*/
|
| 43 |
+
export const WorkletSequencerReturnMessageType = {
|
| 44 |
+
midiEvent: 0, // [...midiEventBytes<number>]
|
| 45 |
+
songChange: 1, // [songIndex<number>, isAutoPlayed<boolean>]
|
| 46 |
+
timeChange: 2, // newAbsoluteTime<number>
|
| 47 |
+
pause: 3, // no data
|
| 48 |
+
getMIDI: 4, // midiData<MIDI>
|
| 49 |
+
midiError: 5, // errorMSG<string>
|
| 50 |
+
metaEvent: 6, // [event<MIDIMessage>, trackNum<number>]
|
| 51 |
+
loopCountChange: 7, // newLoopCount<number>
|
| 52 |
+
songListChange: 8 // songListData<MIDIData[]>
|
| 53 |
+
};
|
spessasynth_lib/sequencer/worklet_sequencer/song_control.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 2 |
+
import { consoleColors, formatTime } from "../../utils/other.js";
|
| 3 |
+
import {
|
| 4 |
+
SpessaSynthGroupCollapsed,
|
| 5 |
+
SpessaSynthGroupEnd,
|
| 6 |
+
SpessaSynthInfo,
|
| 7 |
+
SpessaSynthWarn
|
| 8 |
+
} from "../../utils/loggin.js";
|
| 9 |
+
import { MIDIData } from "../../midi_parser/midi_data.js";
|
| 10 |
+
import { MIDI } from "../../midi_parser/midi_loader.js";
|
| 11 |
+
import { BasicMIDI } from "../../midi_parser/basic_midi.js";
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* @param trackNum {number}
|
| 16 |
+
* @param port {number}
|
| 17 |
+
* @this {WorkletSequencer}
|
| 18 |
+
*/
|
| 19 |
+
export function assignMIDIPort(trackNum, port)
|
| 20 |
+
{
|
| 21 |
+
// do not assign ports to empty tracks
|
| 22 |
+
if (this.midiData.usedChannelsOnTrack[trackNum].size === 0)
|
| 23 |
+
{
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// assign new 16 channels if the port is not occupied yet
|
| 28 |
+
if (this.midiPortChannelOffset === 0)
|
| 29 |
+
{
|
| 30 |
+
this.midiPortChannelOffset += 16;
|
| 31 |
+
this.midiPortChannelOffsets[port] = 0;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (this.midiPortChannelOffsets[port] === undefined)
|
| 35 |
+
{
|
| 36 |
+
if (this.synth.workletProcessorChannels.length < this.midiPortChannelOffset + 15)
|
| 37 |
+
{
|
| 38 |
+
this._addNewMidiPort();
|
| 39 |
+
}
|
| 40 |
+
this.midiPortChannelOffsets[port] = this.midiPortChannelOffset;
|
| 41 |
+
this.midiPortChannelOffset += 16;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
this.midiPorts[trackNum] = port;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Loads a new sequence
|
| 49 |
+
* @param parsedMidi {BasicMIDI}
|
| 50 |
+
* @param autoPlay {boolean}
|
| 51 |
+
* @this {WorkletSequencer}
|
| 52 |
+
*/
|
| 53 |
+
export function loadNewSequence(parsedMidi, autoPlay = true)
|
| 54 |
+
{
|
| 55 |
+
this.stop();
|
| 56 |
+
if (!parsedMidi.tracks)
|
| 57 |
+
{
|
| 58 |
+
throw new Error("This MIDI has no tracks!");
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
this.oneTickToSeconds = 60 / (120 * parsedMidi.timeDivision);
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* @type {BasicMIDI}
|
| 65 |
+
*/
|
| 66 |
+
this.midiData = parsedMidi;
|
| 67 |
+
|
| 68 |
+
// check for embedded soundfont
|
| 69 |
+
if (this.midiData.embeddedSoundFont !== undefined)
|
| 70 |
+
{
|
| 71 |
+
SpessaSynthInfo("%cEmbedded soundfont detected! Using it.", consoleColors.recognized);
|
| 72 |
+
this.synth.setEmbeddedSoundFont(this.midiData.embeddedSoundFont, this.midiData.bankOffset);
|
| 73 |
+
}
|
| 74 |
+
else
|
| 75 |
+
{
|
| 76 |
+
if (this.synth.overrideSoundfont)
|
| 77 |
+
{
|
| 78 |
+
// clean up the embedded soundfont
|
| 79 |
+
this.synth.clearSoundFont(true, true);
|
| 80 |
+
}
|
| 81 |
+
SpessaSynthGroupCollapsed("%cPreloading samples...", consoleColors.info);
|
| 82 |
+
// smart preloading: load only samples used in the midi!
|
| 83 |
+
const used = this.midiData.getUsedProgramsAndKeys(this.synth.soundfontManager);
|
| 84 |
+
for (const [programBank, combos] of Object.entries(used))
|
| 85 |
+
{
|
| 86 |
+
const bank = parseInt(programBank.split(":")[0]);
|
| 87 |
+
const program = parseInt(programBank.split(":")[1]);
|
| 88 |
+
const preset = this.synth.getPreset(bank, program);
|
| 89 |
+
SpessaSynthInfo(
|
| 90 |
+
`%cPreloading used samples on %c${preset.presetName}%c...`,
|
| 91 |
+
consoleColors.info,
|
| 92 |
+
consoleColors.recognized,
|
| 93 |
+
consoleColors.info
|
| 94 |
+
);
|
| 95 |
+
for (const combo of combos)
|
| 96 |
+
{
|
| 97 |
+
const split = combo.split("-");
|
| 98 |
+
preset.preloadSpecific(parseInt(split[0]), parseInt(split[1]));
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
SpessaSynthGroupEnd();
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* the midi track data
|
| 106 |
+
* @type {MIDIMessage[][]}
|
| 107 |
+
*/
|
| 108 |
+
this.tracks = this.midiData.tracks;
|
| 109 |
+
|
| 110 |
+
// copy over the port data
|
| 111 |
+
this.midiPorts = this.midiData.midiPorts.slice();
|
| 112 |
+
|
| 113 |
+
// clear last port data
|
| 114 |
+
this.midiPortChannelOffset = 0;
|
| 115 |
+
this.midiPortChannelOffsets = {};
|
| 116 |
+
// assign port offsets
|
| 117 |
+
this.midiData.midiPorts.forEach((port, trackIndex) =>
|
| 118 |
+
{
|
| 119 |
+
this.assignMIDIPort(trackIndex, port);
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Same as "audio.duration" property (seconds)
|
| 124 |
+
* @type {number}
|
| 125 |
+
*/
|
| 126 |
+
this.duration = this.midiData.duration;
|
| 127 |
+
this.firstNoteTime = this.midiData.MIDIticksToSeconds(this.midiData.firstNoteOn);
|
| 128 |
+
SpessaSynthInfo(`%cTotal song time: ${formatTime(Math.ceil(this.duration)).time}`, consoleColors.recognized);
|
| 129 |
+
|
| 130 |
+
this.post(WorkletSequencerReturnMessageType.songChange, [this.songIndex, autoPlay]);
|
| 131 |
+
|
| 132 |
+
if (this.duration <= 1)
|
| 133 |
+
{
|
| 134 |
+
SpessaSynthWarn(
|
| 135 |
+
`%cVery short song: (${formatTime(Math.round(this.duration)).time}). Disabling loop!`,
|
| 136 |
+
consoleColors.warn
|
| 137 |
+
);
|
| 138 |
+
this.loop = false;
|
| 139 |
+
}
|
| 140 |
+
if (autoPlay)
|
| 141 |
+
{
|
| 142 |
+
this.play(true);
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
// this shall not play: play to the first note and then wait
|
| 147 |
+
const targetTime = this.skipToFirstNoteOn ? this.midiData.firstNoteOn - 1 : 0;
|
| 148 |
+
this.setTimeTicks(targetTime);
|
| 149 |
+
this.pause();
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* @param midiBuffers {MIDIFile[]}
|
| 155 |
+
* @param autoPlay {boolean}
|
| 156 |
+
* @this {WorkletSequencer}
|
| 157 |
+
*/
|
| 158 |
+
export function loadNewSongList(midiBuffers, autoPlay = true)
|
| 159 |
+
{
|
| 160 |
+
/**
|
| 161 |
+
* parse the MIDIs (only the array buffers, MIDI is unchanged)
|
| 162 |
+
* @type {BasicMIDI[]}
|
| 163 |
+
*/
|
| 164 |
+
this.songs = midiBuffers.reduce((mids, b) =>
|
| 165 |
+
{
|
| 166 |
+
if (b.duration)
|
| 167 |
+
{
|
| 168 |
+
mids.push(BasicMIDI.copyFrom(b));
|
| 169 |
+
return mids;
|
| 170 |
+
}
|
| 171 |
+
try
|
| 172 |
+
{
|
| 173 |
+
mids.push(new MIDI(b.binary, b.altName || ""));
|
| 174 |
+
}
|
| 175 |
+
catch (e)
|
| 176 |
+
{
|
| 177 |
+
console.error(e);
|
| 178 |
+
this.post(WorkletSequencerReturnMessageType.midiError, e);
|
| 179 |
+
return mids;
|
| 180 |
+
}
|
| 181 |
+
return mids;
|
| 182 |
+
}, []);
|
| 183 |
+
if (this.songs.length < 1)
|
| 184 |
+
{
|
| 185 |
+
return;
|
| 186 |
+
}
|
| 187 |
+
this.songIndex = 0;
|
| 188 |
+
if (this.songs.length > 1)
|
| 189 |
+
{
|
| 190 |
+
this.loop = false;
|
| 191 |
+
}
|
| 192 |
+
this.shuffleSongIndexes();
|
| 193 |
+
const midiDatas = this.songs.map(s => new MIDIData(s));
|
| 194 |
+
this.post(WorkletSequencerReturnMessageType.songListChange, midiDatas);
|
| 195 |
+
this.loadCurrentSong(autoPlay);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* @this {WorkletSequencer}
|
| 200 |
+
*/
|
| 201 |
+
export function nextSong()
|
| 202 |
+
{
|
| 203 |
+
if (this.songs.length === 1)
|
| 204 |
+
{
|
| 205 |
+
this.currentTime = 0;
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
this.songIndex++;
|
| 209 |
+
this.songIndex %= this.songs.length;
|
| 210 |
+
this.loadCurrentSong();
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* @this {WorkletSequencer}
|
| 215 |
+
*/
|
| 216 |
+
export function previousSong()
|
| 217 |
+
{
|
| 218 |
+
if (this.songs.length === 1)
|
| 219 |
+
{
|
| 220 |
+
this.currentTime = 0;
|
| 221 |
+
return;
|
| 222 |
+
}
|
| 223 |
+
this.songIndex--;
|
| 224 |
+
if (this.songIndex < 0)
|
| 225 |
+
{
|
| 226 |
+
this.songIndex = this.songs.length - 1;
|
| 227 |
+
}
|
| 228 |
+
this.loadCurrentSong();
|
| 229 |
+
}
|
spessasynth_lib/sequencer/worklet_sequencer/worklet_sequencer.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { WorkletSequencerReturnMessageType } from "./sequencer_message.js";
|
| 2 |
+
import { _addNewMidiPort, _processEvent } from "./process_event.js";
|
| 3 |
+
import { _findFirstEventIndex, processTick } from "./process_tick.js";
|
| 4 |
+
import { assignMIDIPort, loadNewSequence, loadNewSongList, nextSong, previousSong } from "./song_control.js";
|
| 5 |
+
import { _playTo, _recalculateStartTime, play, setTimeTicks } from "./play.js";
|
| 6 |
+
import { messageTypes, midiControllers } from "../../midi_parser/midi_message.js";
|
| 7 |
+
import {
|
| 8 |
+
post,
|
| 9 |
+
processMessage,
|
| 10 |
+
sendMIDICC,
|
| 11 |
+
sendMIDIMessage,
|
| 12 |
+
sendMIDIPitchWheel,
|
| 13 |
+
sendMIDIProgramChange,
|
| 14 |
+
sendMIDIReset
|
| 15 |
+
} from "./events.js";
|
| 16 |
+
import { SpessaSynthWarn } from "../../utils/loggin.js";
|
| 17 |
+
|
| 18 |
+
import { MIDI_CHANNEL_COUNT } from "../../synthetizer/synth_constants.js";
|
| 19 |
+
|
| 20 |
+
class WorkletSequencer
|
| 21 |
+
{
|
| 22 |
+
/**
|
| 23 |
+
* All the sequencer's songs
|
| 24 |
+
* @type {BasicMIDI[]}
|
| 25 |
+
*/
|
| 26 |
+
songs = [];
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Current song index
|
| 30 |
+
* @type {number}
|
| 31 |
+
*/
|
| 32 |
+
songIndex = 0;
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* shuffled song indexes
|
| 36 |
+
* @type {number[]}
|
| 37 |
+
*/
|
| 38 |
+
shuffledSongIndexes = [];
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* the synth to use
|
| 42 |
+
* @type {SpessaSynthProcessor}
|
| 43 |
+
*/
|
| 44 |
+
synth;
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* if the sequencer is active
|
| 48 |
+
* @type {boolean}
|
| 49 |
+
*/
|
| 50 |
+
isActive = false;
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* If the event should instead be sent back to the main thread instead of synth
|
| 54 |
+
* @type {boolean}
|
| 55 |
+
*/
|
| 56 |
+
sendMIDIMessages = false;
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* sequencer's loop count
|
| 60 |
+
* @type {number}
|
| 61 |
+
*/
|
| 62 |
+
loopCount = Infinity;
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* event's number in this.events
|
| 66 |
+
* @type {number[]}
|
| 67 |
+
*/
|
| 68 |
+
eventIndex = [];
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* tracks the time that has already been played
|
| 72 |
+
* @type {number}
|
| 73 |
+
*/
|
| 74 |
+
playedTime = 0;
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* The (relative) time when the sequencer was paused. If it's not paused, then it's undefined.
|
| 78 |
+
* @type {number}
|
| 79 |
+
*/
|
| 80 |
+
pausedTime = undefined;
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Absolute playback startTime, bases on the synth's time
|
| 84 |
+
* @type {number}
|
| 85 |
+
*/
|
| 86 |
+
absoluteStartTime = currentTime;
|
| 87 |
+
/**
|
| 88 |
+
* Currently playing notes (for pausing and resuming)
|
| 89 |
+
* @type {{
|
| 90 |
+
* midiNote: number,
|
| 91 |
+
* channel: number,
|
| 92 |
+
* velocity: number
|
| 93 |
+
* }[]}
|
| 94 |
+
*/
|
| 95 |
+
playingNotes = [];
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* controls if the sequencer loops (defaults to true)
|
| 99 |
+
* @type {boolean}
|
| 100 |
+
*/
|
| 101 |
+
loop = true;
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* controls if the songs are ordered randomly
|
| 105 |
+
* @type {boolean}
|
| 106 |
+
*/
|
| 107 |
+
shuffleMode = false;
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* the current track data
|
| 111 |
+
* @type {BasicMIDI}
|
| 112 |
+
*/
|
| 113 |
+
midiData = undefined;
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* midi port number for the corresponding track
|
| 117 |
+
* @type {number[]}
|
| 118 |
+
*/
|
| 119 |
+
midiPorts = [];
|
| 120 |
+
midiPortChannelOffset = 0;
|
| 121 |
+
/**
|
| 122 |
+
* stored as:
|
| 123 |
+
* Object<midi port, channel offset>
|
| 124 |
+
* @type {Object<number, number>}
|
| 125 |
+
*/
|
| 126 |
+
midiPortChannelOffsets = {};
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* @type {boolean}
|
| 130 |
+
*/
|
| 131 |
+
skipToFirstNoteOn = true;
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* If true, seq will stay paused when seeking or changing the playback rate
|
| 135 |
+
* @type {boolean}
|
| 136 |
+
*/
|
| 137 |
+
preservePlaybackState = false;
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* @param spessasynthProcessor {SpessaSynthProcessor}
|
| 141 |
+
*/
|
| 142 |
+
constructor(spessasynthProcessor)
|
| 143 |
+
{
|
| 144 |
+
this.synth = spessasynthProcessor;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/**
|
| 148 |
+
* Controls the playback's rate
|
| 149 |
+
* @type {number}
|
| 150 |
+
* @private
|
| 151 |
+
*/
|
| 152 |
+
_playbackRate = 1;
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* @param value {number}
|
| 156 |
+
*/
|
| 157 |
+
set playbackRate(value)
|
| 158 |
+
{
|
| 159 |
+
const time = this.currentTime;
|
| 160 |
+
this._playbackRate = value;
|
| 161 |
+
this.currentTime = time;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
get currentTime()
|
| 165 |
+
{
|
| 166 |
+
// return the paused time if it's set to something other than undefined
|
| 167 |
+
if (this.pausedTime !== undefined)
|
| 168 |
+
{
|
| 169 |
+
return this.pausedTime;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
return (currentTime - this.absoluteStartTime) * this._playbackRate;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
set currentTime(time)
|
| 176 |
+
{
|
| 177 |
+
if (time > this.duration || time < 0)
|
| 178 |
+
{
|
| 179 |
+
// time is 0
|
| 180 |
+
if (this.skipToFirstNoteOn)
|
| 181 |
+
{
|
| 182 |
+
this.setTimeTicks(this.midiData.firstNoteOn - 1);
|
| 183 |
+
}
|
| 184 |
+
else
|
| 185 |
+
{
|
| 186 |
+
this.setTimeTicks(0);
|
| 187 |
+
}
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
if (this.skipToFirstNoteOn)
|
| 191 |
+
{
|
| 192 |
+
if (time < this.firstNoteTime)
|
| 193 |
+
{
|
| 194 |
+
this.setTimeTicks(this.midiData.firstNoteOn - 1);
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
this.stop();
|
| 199 |
+
this.playingNotes = [];
|
| 200 |
+
const wasPaused = this.paused && this.preservePlaybackState;
|
| 201 |
+
this.pausedTime = undefined;
|
| 202 |
+
this.post(WorkletSequencerReturnMessageType.timeChange, currentTime - time);
|
| 203 |
+
if (this.midiData.duration === 0)
|
| 204 |
+
{
|
| 205 |
+
SpessaSynthWarn("No duration!");
|
| 206 |
+
this.post(WorkletSequencerReturnMessageType.pause, true);
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
this._playTo(time);
|
| 210 |
+
this._recalculateStartTime(time);
|
| 211 |
+
if (wasPaused)
|
| 212 |
+
{
|
| 213 |
+
this.pause();
|
| 214 |
+
}
|
| 215 |
+
else
|
| 216 |
+
{
|
| 217 |
+
this.play();
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* true if paused, false if playing or stopped
|
| 223 |
+
* @returns {boolean}
|
| 224 |
+
*/
|
| 225 |
+
get paused()
|
| 226 |
+
{
|
| 227 |
+
return this.pausedTime !== undefined;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/**
|
| 231 |
+
* Pauses the playback
|
| 232 |
+
* @param isFinished {boolean}
|
| 233 |
+
*/
|
| 234 |
+
pause(isFinished = false)
|
| 235 |
+
{
|
| 236 |
+
if (this.paused)
|
| 237 |
+
{
|
| 238 |
+
SpessaSynthWarn("Already paused");
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
this.pausedTime = this.currentTime;
|
| 242 |
+
this.stop();
|
| 243 |
+
this.post(WorkletSequencerReturnMessageType.pause, isFinished);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* Stops the playback
|
| 248 |
+
*/
|
| 249 |
+
stop()
|
| 250 |
+
{
|
| 251 |
+
this.clearProcessHandler();
|
| 252 |
+
// disable sustain
|
| 253 |
+
for (let i = 0; i < 16; i++)
|
| 254 |
+
{
|
| 255 |
+
this.synth.controllerChange(i, midiControllers.sustainPedal, 0);
|
| 256 |
+
}
|
| 257 |
+
this.synth.stopAllChannels();
|
| 258 |
+
if (this.sendMIDIMessages)
|
| 259 |
+
{
|
| 260 |
+
for (let note of this.playingNotes)
|
| 261 |
+
{
|
| 262 |
+
this.sendMIDIMessage([messageTypes.noteOff | (note.channel % 16), note.midiNote]);
|
| 263 |
+
}
|
| 264 |
+
for (let c = 0; c < MIDI_CHANNEL_COUNT; c++)
|
| 265 |
+
{
|
| 266 |
+
this.sendMIDICC(c, midiControllers.allNotesOff, 0);
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
loadCurrentSong(autoPlay = true)
|
| 272 |
+
{
|
| 273 |
+
let index = this.songIndex;
|
| 274 |
+
if (this.shuffleMode)
|
| 275 |
+
{
|
| 276 |
+
index = this.shuffledSongIndexes[this.songIndex];
|
| 277 |
+
}
|
| 278 |
+
this.loadNewSequence(this.songs[index], autoPlay);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
_resetTimers()
|
| 282 |
+
{
|
| 283 |
+
this.playedTime = 0;
|
| 284 |
+
this.eventIndex = Array(this.tracks.length).fill(0);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
setProcessHandler()
|
| 288 |
+
{
|
| 289 |
+
this.isActive = true;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
clearProcessHandler()
|
| 293 |
+
{
|
| 294 |
+
this.isActive = false;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
shuffleSongIndexes()
|
| 298 |
+
{
|
| 299 |
+
const indexes = this.songs.map((_, i) => i);
|
| 300 |
+
this.shuffledSongIndexes = [];
|
| 301 |
+
while (indexes.length > 0)
|
| 302 |
+
{
|
| 303 |
+
const index = indexes[Math.floor(Math.random() * indexes.length)];
|
| 304 |
+
this.shuffledSongIndexes.push(index);
|
| 305 |
+
indexes.splice(indexes.indexOf(index), 1);
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Web MIDI sending
|
| 311 |
+
WorkletSequencer.prototype.sendMIDIMessage = sendMIDIMessage;
|
| 312 |
+
WorkletSequencer.prototype.sendMIDIReset = sendMIDIReset;
|
| 313 |
+
WorkletSequencer.prototype.sendMIDICC = sendMIDICC;
|
| 314 |
+
WorkletSequencer.prototype.sendMIDIProgramChange = sendMIDIProgramChange;
|
| 315 |
+
WorkletSequencer.prototype.sendMIDIPitchWheel = sendMIDIPitchWheel;
|
| 316 |
+
WorkletSequencer.prototype.assignMIDIPort = assignMIDIPort;
|
| 317 |
+
|
| 318 |
+
WorkletSequencer.prototype.post = post;
|
| 319 |
+
WorkletSequencer.prototype.processMessage = processMessage;
|
| 320 |
+
|
| 321 |
+
WorkletSequencer.prototype._processEvent = _processEvent;
|
| 322 |
+
WorkletSequencer.prototype._addNewMidiPort = _addNewMidiPort;
|
| 323 |
+
WorkletSequencer.prototype.processTick = processTick;
|
| 324 |
+
WorkletSequencer.prototype._findFirstEventIndex = _findFirstEventIndex;
|
| 325 |
+
|
| 326 |
+
WorkletSequencer.prototype.loadNewSequence = loadNewSequence;
|
| 327 |
+
WorkletSequencer.prototype.loadNewSongList = loadNewSongList;
|
| 328 |
+
WorkletSequencer.prototype.nextSong = nextSong;
|
| 329 |
+
WorkletSequencer.prototype.previousSong = previousSong;
|
| 330 |
+
|
| 331 |
+
WorkletSequencer.prototype.play = play;
|
| 332 |
+
WorkletSequencer.prototype._playTo = _playTo;
|
| 333 |
+
WorkletSequencer.prototype.setTimeTicks = setTimeTicks;
|
| 334 |
+
WorkletSequencer.prototype._recalculateStartTime = _recalculateStartTime;
|
| 335 |
+
|
| 336 |
+
export { WorkletSequencer };
|
spessasynth_lib/soundfont/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## This is the SoundFont2 parsing library.
|
| 2 |
+
|
| 3 |
+
The code here is responsible for parsing the SoundFont2 file and
|
| 4 |
+
providing an easy way to get the data out.
|
| 5 |
+
Default modulators are also stored here (in `modulators.js`)
|
| 6 |
+
|
| 7 |
+
`basic_soundfont` folder contains the classes that represent the soundfont file.
|
| 8 |
+
|
| 9 |
+
`read_sf2` folder contains the code for reading an `.sf2` file.
|
| 10 |
+
|
| 11 |
+
`write` folder contains the code for writing out an `.sf2` file.
|
| 12 |
+
|
| 13 |
+
`dls` folder contains the code for reading a `.dls` file (and converting in into a soundfont representation).
|
spessasynth_lib/soundfont/basic_soundfont/basic_instrument.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export class BasicInstrument
|
| 2 |
+
{
|
| 3 |
+
/**
|
| 4 |
+
* The instrument's name
|
| 5 |
+
* @type {string}
|
| 6 |
+
*/
|
| 7 |
+
instrumentName = "";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* The instrument's zones
|
| 11 |
+
* @type {BasicInstrumentZone[]}
|
| 12 |
+
*/
|
| 13 |
+
instrumentZones = [];
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Instrument's use count, used for trimming
|
| 17 |
+
* @type {number}
|
| 18 |
+
* @private
|
| 19 |
+
*/
|
| 20 |
+
_useCount = 0;
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* @returns {number}
|
| 24 |
+
*/
|
| 25 |
+
get useCount()
|
| 26 |
+
{
|
| 27 |
+
return this._useCount;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
addUseCount()
|
| 31 |
+
{
|
| 32 |
+
this._useCount++;
|
| 33 |
+
this.instrumentZones.forEach(z => z.useCount++);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
removeUseCount()
|
| 37 |
+
{
|
| 38 |
+
this._useCount--;
|
| 39 |
+
for (let i = 0; i < this.instrumentZones.length; i++)
|
| 40 |
+
{
|
| 41 |
+
if (this.safeDeleteZone(i))
|
| 42 |
+
{
|
| 43 |
+
i--;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
deleteInstrument()
|
| 49 |
+
{
|
| 50 |
+
this.instrumentZones.forEach(z => z.deleteZone());
|
| 51 |
+
this.instrumentZones.length = 0;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* @param index {number}
|
| 56 |
+
* @returns {boolean} is the zone has been deleted
|
| 57 |
+
*/
|
| 58 |
+
safeDeleteZone(index)
|
| 59 |
+
{
|
| 60 |
+
this.instrumentZones[index].useCount--;
|
| 61 |
+
if (this.instrumentZones[index].useCount < 1)
|
| 62 |
+
{
|
| 63 |
+
this.deleteZone(index);
|
| 64 |
+
return true;
|
| 65 |
+
}
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* @param index {number}
|
| 71 |
+
*/
|
| 72 |
+
deleteZone(index)
|
| 73 |
+
{
|
| 74 |
+
this.instrumentZones[index].deleteZone();
|
| 75 |
+
this.instrumentZones.splice(index, 1);
|
| 76 |
+
}
|
| 77 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/basic_preset.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @typedef {{
|
| 3 |
+
* instrumentGenerators: Generator[],
|
| 4 |
+
* presetGenerators: Generator[],
|
| 5 |
+
* modulators: Modulator[],
|
| 6 |
+
* sample: BasicSample,
|
| 7 |
+
* sampleID: number,
|
| 8 |
+
* }} SampleAndGenerators
|
| 9 |
+
*/
|
| 10 |
+
import { generatorTypes } from "./generator.js";
|
| 11 |
+
import { Modulator } from "./modulator.js";
|
| 12 |
+
import { isXGDrums } from "../../utils/xg_hacks.js";
|
| 13 |
+
|
| 14 |
+
export class BasicPreset
|
| 15 |
+
{
|
| 16 |
+
/**
|
| 17 |
+
* The parent soundbank instance
|
| 18 |
+
* Currently used for determining default modulators and XG status
|
| 19 |
+
* @type {BasicSoundBank}
|
| 20 |
+
*/
|
| 21 |
+
parentSoundBank;
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* The preset's name
|
| 25 |
+
* @type {string}
|
| 26 |
+
*/
|
| 27 |
+
presetName = "";
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* The preset's MIDI program number
|
| 31 |
+
* @type {number}
|
| 32 |
+
*/
|
| 33 |
+
program = 0;
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* The preset's MIDI bank number
|
| 37 |
+
* @type {number}
|
| 38 |
+
*/
|
| 39 |
+
bank = 0;
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* The preset's zones
|
| 43 |
+
* @type {BasicPresetZone[]}
|
| 44 |
+
*/
|
| 45 |
+
presetZones = [];
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Stores already found getSamplesAndGenerators for reuse
|
| 49 |
+
* @type {SampleAndGenerators[][][]}
|
| 50 |
+
*/
|
| 51 |
+
foundSamplesAndGenerators = [];
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* unused metadata
|
| 55 |
+
* @type {number}
|
| 56 |
+
*/
|
| 57 |
+
library = 0;
|
| 58 |
+
/**
|
| 59 |
+
* unused metadata
|
| 60 |
+
* @type {number}
|
| 61 |
+
*/
|
| 62 |
+
genre = 0;
|
| 63 |
+
/**
|
| 64 |
+
* unused metadata
|
| 65 |
+
* @type {number}
|
| 66 |
+
*/
|
| 67 |
+
morphology = 0;
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Creates a new preset representation
|
| 71 |
+
* @param parentSoundBank {BasicSoundBank}
|
| 72 |
+
*/
|
| 73 |
+
constructor(parentSoundBank)
|
| 74 |
+
{
|
| 75 |
+
this.parentSoundBank = parentSoundBank;
|
| 76 |
+
for (let i = 0; i < 128; i++)
|
| 77 |
+
{
|
| 78 |
+
this.foundSamplesAndGenerators[i] = [];
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* @param allowXG {boolean}
|
| 84 |
+
* @param allowSFX {boolean}
|
| 85 |
+
* @returns {boolean}
|
| 86 |
+
*/
|
| 87 |
+
isDrumPreset(allowXG, allowSFX = false)
|
| 88 |
+
{
|
| 89 |
+
const xg = allowXG && this.parentSoundBank.isXGBank;
|
| 90 |
+
// sfx is not cool
|
| 91 |
+
return this.bank === 128 || (
|
| 92 |
+
xg &&
|
| 93 |
+
(isXGDrums(this.bank) && (this.bank !== 126 || allowSFX))
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
deletePreset()
|
| 98 |
+
{
|
| 99 |
+
this.presetZones.forEach(z => z.deleteZone());
|
| 100 |
+
this.presetZones.length = 0;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* @param index {number}
|
| 105 |
+
*/
|
| 106 |
+
deleteZone(index)
|
| 107 |
+
{
|
| 108 |
+
this.presetZones[index].deleteZone();
|
| 109 |
+
this.presetZones.splice(index, 1);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// noinspection JSUnusedGlobalSymbols
|
| 113 |
+
/**
|
| 114 |
+
* Preloads all samples (async)
|
| 115 |
+
*/
|
| 116 |
+
preload(keyMin, keyMax)
|
| 117 |
+
{
|
| 118 |
+
for (let key = keyMin; key < keyMax + 1; key++)
|
| 119 |
+
{
|
| 120 |
+
for (let velocity = 0; velocity < 128; velocity++)
|
| 121 |
+
{
|
| 122 |
+
this.getSamplesAndGenerators(key, velocity).forEach(samandgen =>
|
| 123 |
+
{
|
| 124 |
+
if (!samandgen.sample.isSampleLoaded)
|
| 125 |
+
{
|
| 126 |
+
samandgen.sample.getAudioData();
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Preloads a specific key/velocity combo
|
| 135 |
+
* @param key {number}
|
| 136 |
+
* @param velocity {number}
|
| 137 |
+
*/
|
| 138 |
+
preloadSpecific(key, velocity)
|
| 139 |
+
{
|
| 140 |
+
this.getSamplesAndGenerators(key, velocity).forEach(samandgen =>
|
| 141 |
+
{
|
| 142 |
+
if (!samandgen.sample.isSampleLoaded)
|
| 143 |
+
{
|
| 144 |
+
samandgen.sample.getAudioData();
|
| 145 |
+
}
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Returns generatorTranslator and generators for given note
|
| 151 |
+
* @param midiNote {number}
|
| 152 |
+
* @param velocity {number}
|
| 153 |
+
* @returns {SampleAndGenerators[]}
|
| 154 |
+
*/
|
| 155 |
+
getSamplesAndGenerators(midiNote, velocity)
|
| 156 |
+
{
|
| 157 |
+
const memorized = this.foundSamplesAndGenerators[midiNote][velocity];
|
| 158 |
+
if (memorized)
|
| 159 |
+
{
|
| 160 |
+
return memorized;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (this.presetZones.length < 1)
|
| 164 |
+
{
|
| 165 |
+
return [];
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* @param range {SoundFontRange}
|
| 170 |
+
* @param number {number}
|
| 171 |
+
* @returns {boolean}
|
| 172 |
+
*/
|
| 173 |
+
function isInRange(range, number)
|
| 174 |
+
{
|
| 175 |
+
return number >= range.min && number <= range.max;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* @param main {Generator[]}
|
| 180 |
+
* @param adder {Generator[]}
|
| 181 |
+
*/
|
| 182 |
+
function addUnique(main, adder)
|
| 183 |
+
{
|
| 184 |
+
main.push(...adder.filter(g => !main.find(mg => mg.generatorType === g.generatorType)));
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* @param main {Modulator[]}
|
| 189 |
+
* @param adder {Modulator[]}
|
| 190 |
+
*/
|
| 191 |
+
function addUniqueMods(main, adder)
|
| 192 |
+
{
|
| 193 |
+
main.push(...adder.filter(m => !main.find(mm => Modulator.isIdentical(m, mm))));
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* @type {SampleAndGenerators[]}
|
| 198 |
+
*/
|
| 199 |
+
let parsedGeneratorsAndSamples = [];
|
| 200 |
+
|
| 201 |
+
/**
|
| 202 |
+
* global zone is always first, so it or nothing
|
| 203 |
+
* @type {Generator[]}
|
| 204 |
+
*/
|
| 205 |
+
let globalPresetGenerators = this.presetZones[0].isGlobal ? [...this.presetZones[0].generators] : [];
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* @type {Modulator[]}
|
| 209 |
+
*/
|
| 210 |
+
let globalPresetModulators = this.presetZones[0].isGlobal ? [...this.presetZones[0].modulators] : [];
|
| 211 |
+
const globalKeyRange = this.presetZones[0].isGlobal ? this.presetZones[0].keyRange : { min: 0, max: 127 };
|
| 212 |
+
const globalVelRange = this.presetZones[0].isGlobal ? this.presetZones[0].velRange : { min: 0, max: 127 };
|
| 213 |
+
|
| 214 |
+
// find the preset zones in range
|
| 215 |
+
let presetZonesInRange = this.presetZones.filter(currentZone =>
|
| 216 |
+
(
|
| 217 |
+
isInRange(
|
| 218 |
+
currentZone.hasKeyRange ? currentZone.keyRange : globalKeyRange,
|
| 219 |
+
midiNote
|
| 220 |
+
)
|
| 221 |
+
&&
|
| 222 |
+
isInRange(
|
| 223 |
+
currentZone.hasVelRange ? currentZone.velRange : globalVelRange,
|
| 224 |
+
velocity
|
| 225 |
+
)
|
| 226 |
+
) && !currentZone.isGlobal);
|
| 227 |
+
|
| 228 |
+
presetZonesInRange.forEach(zone =>
|
| 229 |
+
{
|
| 230 |
+
// the global zone is already taken into account earlier
|
| 231 |
+
if (zone.instrument.instrumentZones.length < 1)
|
| 232 |
+
{
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
let presetGenerators = zone.generators;
|
| 236 |
+
let presetModulators = zone.modulators;
|
| 237 |
+
const firstZone = zone.instrument.instrumentZones[0];
|
| 238 |
+
/**
|
| 239 |
+
* global zone is always first, so it or nothing
|
| 240 |
+
* @type {Generator[]}
|
| 241 |
+
*/
|
| 242 |
+
let globalInstrumentGenerators = firstZone.isGlobal ? [...firstZone.generators] : [];
|
| 243 |
+
let globalInstrumentModulators = firstZone.isGlobal ? [...firstZone.modulators] : [];
|
| 244 |
+
const globalKeyRange = firstZone.isGlobal ? firstZone.keyRange : { min: 0, max: 127 };
|
| 245 |
+
const globalVelRange = firstZone.isGlobal ? firstZone.velRange : { min: 0, max: 127 };
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
let instrumentZonesInRange = zone.instrument.instrumentZones
|
| 249 |
+
.filter(currentZone =>
|
| 250 |
+
(
|
| 251 |
+
isInRange(
|
| 252 |
+
currentZone.hasKeyRange ? currentZone.keyRange : globalKeyRange,
|
| 253 |
+
midiNote
|
| 254 |
+
)
|
| 255 |
+
&&
|
| 256 |
+
isInRange(
|
| 257 |
+
currentZone.hasVelRange ? currentZone.velRange : globalVelRange,
|
| 258 |
+
velocity
|
| 259 |
+
)
|
| 260 |
+
) && !currentZone.isGlobal
|
| 261 |
+
);
|
| 262 |
+
|
| 263 |
+
instrumentZonesInRange.forEach(instrumentZone =>
|
| 264 |
+
{
|
| 265 |
+
let instrumentGenerators = [...instrumentZone.generators];
|
| 266 |
+
let instrumentModulators = [...instrumentZone.modulators];
|
| 267 |
+
|
| 268 |
+
addUnique(
|
| 269 |
+
presetGenerators,
|
| 270 |
+
globalPresetGenerators
|
| 271 |
+
);
|
| 272 |
+
// add the unique global preset generators (local replace global(
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
// add the unique global instrument generators (local replace global)
|
| 276 |
+
addUnique(
|
| 277 |
+
instrumentGenerators,
|
| 278 |
+
globalInstrumentGenerators
|
| 279 |
+
);
|
| 280 |
+
|
| 281 |
+
addUniqueMods(
|
| 282 |
+
presetModulators,
|
| 283 |
+
globalPresetModulators
|
| 284 |
+
);
|
| 285 |
+
addUniqueMods(
|
| 286 |
+
instrumentModulators,
|
| 287 |
+
globalInstrumentModulators
|
| 288 |
+
);
|
| 289 |
+
|
| 290 |
+
// default mods
|
| 291 |
+
addUniqueMods(
|
| 292 |
+
instrumentModulators,
|
| 293 |
+
this.parentSoundBank.defaultModulators
|
| 294 |
+
);
|
| 295 |
+
|
| 296 |
+
/**
|
| 297 |
+
* sum preset modulators to instruments (amount) sf spec page 54
|
| 298 |
+
* @type {Modulator[]}
|
| 299 |
+
*/
|
| 300 |
+
const finalModulatorList = [...instrumentModulators];
|
| 301 |
+
for (let i = 0; i < presetModulators.length; i++)
|
| 302 |
+
{
|
| 303 |
+
let mod = presetModulators[i];
|
| 304 |
+
const identicalInstrumentModulator = finalModulatorList.findIndex(
|
| 305 |
+
m => Modulator.isIdentical(mod, m));
|
| 306 |
+
if (identicalInstrumentModulator !== -1)
|
| 307 |
+
{
|
| 308 |
+
// sum the amounts
|
| 309 |
+
// (this makes a new modulator because otherwise it would overwrite the one in the soundfont!
|
| 310 |
+
finalModulatorList[identicalInstrumentModulator] = finalModulatorList[identicalInstrumentModulator].sumTransform(
|
| 311 |
+
mod);
|
| 312 |
+
}
|
| 313 |
+
else
|
| 314 |
+
{
|
| 315 |
+
finalModulatorList.push(mod);
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
// combine both generators and add to the final result
|
| 321 |
+
parsedGeneratorsAndSamples.push({
|
| 322 |
+
instrumentGenerators: instrumentGenerators,
|
| 323 |
+
presetGenerators: presetGenerators,
|
| 324 |
+
modulators: finalModulatorList,
|
| 325 |
+
sample: instrumentZone.sample,
|
| 326 |
+
sampleID: instrumentZone.generators.find(
|
| 327 |
+
g => g.generatorType === generatorTypes.sampleID).generatorValue
|
| 328 |
+
});
|
| 329 |
+
});
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
// save and return
|
| 333 |
+
this.foundSamplesAndGenerators[midiNote][velocity] = parsedGeneratorsAndSamples;
|
| 334 |
+
return parsedGeneratorsAndSamples;
|
| 335 |
+
}
|
| 336 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/basic_sample.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* samples.js
|
| 3 |
+
* purpose: parses soundfont samples, resamples if needed.
|
| 4 |
+
* loads sample data, handles async loading of sf3 compressed samples
|
| 5 |
+
*/
|
| 6 |
+
import { SpessaSynthWarn } from "../../utils/loggin.js";
|
| 7 |
+
|
| 8 |
+
// should be reasonable for most cases
|
| 9 |
+
const RESAMPLE_RATE = 48000;
|
| 10 |
+
|
| 11 |
+
export class BasicSample
|
| 12 |
+
{
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* The sample's name
|
| 16 |
+
* @type {string}
|
| 17 |
+
*/
|
| 18 |
+
sampleName;
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Sample rate in Hz
|
| 22 |
+
* @type {number}
|
| 23 |
+
*/
|
| 24 |
+
sampleRate;
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Original pitch of the sample as a MIDI note number
|
| 28 |
+
* @type {number}
|
| 29 |
+
*/
|
| 30 |
+
samplePitch;
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Pitch correction, in cents. Can be negative
|
| 34 |
+
* @type {number}
|
| 35 |
+
*/
|
| 36 |
+
samplePitchCorrection;
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Sample link, currently unused here
|
| 40 |
+
* @type {number}
|
| 41 |
+
*/
|
| 42 |
+
sampleLink;
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Type of the sample, currently only used for SF3
|
| 46 |
+
* @type {number}
|
| 47 |
+
*/
|
| 48 |
+
sampleType;
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Relative to the start of the sample in sample points
|
| 52 |
+
* @type {number}
|
| 53 |
+
*/
|
| 54 |
+
sampleLoopStartIndex;
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Relative to the start of the sample in sample points
|
| 58 |
+
* @type {number}
|
| 59 |
+
*/
|
| 60 |
+
sampleLoopEndIndex;
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Indicates if the sample is compressed
|
| 64 |
+
* @type {boolean}
|
| 65 |
+
*/
|
| 66 |
+
isCompressed;
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* The compressed sample data if it was compressed by spessasynth
|
| 70 |
+
* @type {Uint8Array}
|
| 71 |
+
*/
|
| 72 |
+
compressedData = undefined;
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* The sample's use count
|
| 76 |
+
* @type {number}
|
| 77 |
+
*/
|
| 78 |
+
useCount = 0;
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* The sample's audio data
|
| 82 |
+
* @type {Float32Array}
|
| 83 |
+
*/
|
| 84 |
+
sampleData = undefined;
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* The basic representation of a soundfont sample
|
| 88 |
+
* @param sampleName {string} The sample's name
|
| 89 |
+
* @param sampleRate {number} The sample's rate in Hz
|
| 90 |
+
* @param samplePitch {number} The sample's pitch as a MIDI note number
|
| 91 |
+
* @param samplePitchCorrection {number} The sample's pitch correction in cents
|
| 92 |
+
* @param sampleLink {number} The sample's link, currently unused
|
| 93 |
+
* @param sampleType {number} The sample's type, an enum
|
| 94 |
+
* @param loopStart {number} The sample's loop start relative to the sample start in sample points
|
| 95 |
+
* @param loopEnd {number} The sample's loop end relative to the sample start in sample points
|
| 96 |
+
*/
|
| 97 |
+
constructor(
|
| 98 |
+
sampleName,
|
| 99 |
+
sampleRate,
|
| 100 |
+
samplePitch,
|
| 101 |
+
samplePitchCorrection,
|
| 102 |
+
sampleLink,
|
| 103 |
+
sampleType,
|
| 104 |
+
loopStart,
|
| 105 |
+
loopEnd
|
| 106 |
+
)
|
| 107 |
+
{
|
| 108 |
+
this.sampleName = sampleName;
|
| 109 |
+
this.sampleRate = sampleRate;
|
| 110 |
+
this.samplePitch = samplePitch;
|
| 111 |
+
this.samplePitchCorrection = samplePitchCorrection;
|
| 112 |
+
this.sampleLink = sampleLink;
|
| 113 |
+
this.sampleType = sampleType;
|
| 114 |
+
this.sampleLoopStartIndex = loopStart;
|
| 115 |
+
this.sampleLoopEndIndex = loopEnd;
|
| 116 |
+
// https://github.com/FluidSynth/fluidsynth/wiki/SoundFont3Format
|
| 117 |
+
this.isCompressed = (sampleType & 0x10) > 0;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* @returns {Uint8Array|IndexedByteArray}
|
| 123 |
+
*/
|
| 124 |
+
getRawData()
|
| 125 |
+
{
|
| 126 |
+
const uint8 = new Uint8Array(this.sampleData.length * 2);
|
| 127 |
+
for (let i = 0; i < this.sampleData.length; i++)
|
| 128 |
+
{
|
| 129 |
+
const sample = Math.floor(this.sampleData[i] * 32768);
|
| 130 |
+
uint8[i * 2] = sample & 0xFF; // lower byte
|
| 131 |
+
uint8[i * 2 + 1] = (sample >> 8) & 0xFF; // upper byte
|
| 132 |
+
}
|
| 133 |
+
return uint8;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
resampleData(newSampleRate)
|
| 137 |
+
{
|
| 138 |
+
let audioData = this.getAudioData();
|
| 139 |
+
const ratio = newSampleRate / this.sampleRate;
|
| 140 |
+
const resampled = new Float32Array(Math.floor(audioData.length * ratio));
|
| 141 |
+
for (let i = 0; i < resampled.length; i++)
|
| 142 |
+
{
|
| 143 |
+
resampled[i] = audioData[Math.floor(i * (1 / ratio))];
|
| 144 |
+
}
|
| 145 |
+
audioData = resampled;
|
| 146 |
+
this.sampleRate = newSampleRate;
|
| 147 |
+
// adjust loop points
|
| 148 |
+
this.sampleLoopStartIndex = Math.floor(this.sampleLoopStartIndex * ratio);
|
| 149 |
+
this.sampleLoopEndIndex = Math.floor(this.sampleLoopEndIndex * ratio);
|
| 150 |
+
this.sampleData = audioData;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* @param quality {number}
|
| 155 |
+
* @param encodeVorbis {EncodeVorbisFunction}
|
| 156 |
+
*/
|
| 157 |
+
compressSample(quality, encodeVorbis)
|
| 158 |
+
{
|
| 159 |
+
// no need to compress
|
| 160 |
+
if (this.isCompressed)
|
| 161 |
+
{
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
// compress, always mono!
|
| 165 |
+
try
|
| 166 |
+
{
|
| 167 |
+
// if the sample rate is too low or too high, resample
|
| 168 |
+
let audioData = this.getAudioData();
|
| 169 |
+
if (this.sampleRate < 8000 || this.sampleRate > 96000)
|
| 170 |
+
{
|
| 171 |
+
this.resampleData(RESAMPLE_RATE);
|
| 172 |
+
audioData = this.getAudioData();
|
| 173 |
+
}
|
| 174 |
+
this.compressedData = encodeVorbis([audioData], 1, this.sampleRate, quality);
|
| 175 |
+
// flag as compressed
|
| 176 |
+
this.sampleType |= 0x10;
|
| 177 |
+
this.isCompressed = true;
|
| 178 |
+
}
|
| 179 |
+
catch (e)
|
| 180 |
+
{
|
| 181 |
+
SpessaSynthWarn(`Failed to compress ${this.sampleName}. Leaving as uncompressed!`);
|
| 182 |
+
this.isCompressed = false;
|
| 183 |
+
this.compressedData = undefined;
|
| 184 |
+
// flag as uncompressed
|
| 185 |
+
this.sampleType &= 0xEF;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/**
|
| 191 |
+
* @returns {Float32Array}
|
| 192 |
+
*/
|
| 193 |
+
getAudioData()
|
| 194 |
+
{
|
| 195 |
+
return this.sampleData;
|
| 196 |
+
}
|
| 197 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/basic_soundfont.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
SpessaSynthGroup,
|
| 3 |
+
SpessaSynthGroupCollapsed,
|
| 4 |
+
SpessaSynthGroupEnd,
|
| 5 |
+
SpessaSynthInfo,
|
| 6 |
+
SpessaSynthWarn
|
| 7 |
+
} from "../../utils/loggin.js";
|
| 8 |
+
import { consoleColors } from "../../utils/other.js";
|
| 9 |
+
import { write } from "./write_sf2/write.js";
|
| 10 |
+
import { defaultModulators, Modulator } from "./modulator.js";
|
| 11 |
+
import { writeDLS } from "./write_dls/write_dls.js";
|
| 12 |
+
import { BasicSample } from "./basic_sample.js";
|
| 13 |
+
import { BasicInstrumentZone, BasicPresetZone } from "./basic_zones.js";
|
| 14 |
+
import { Generator, generatorTypes } from "./generator.js";
|
| 15 |
+
import { BasicInstrument } from "./basic_instrument.js";
|
| 16 |
+
import { BasicPreset } from "./basic_preset.js";
|
| 17 |
+
import { isXGDrums } from "../../utils/xg_hacks.js";
|
| 18 |
+
|
| 19 |
+
class BasicSoundBank
|
| 20 |
+
{
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Soundfont's info stored as name: value. ifil and iver are stored as string representation of float (e.g., 2.1)
|
| 24 |
+
* @type {Object<string, string|IndexedByteArray>}
|
| 25 |
+
*/
|
| 26 |
+
soundFontInfo = {};
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* The soundfont's presets
|
| 30 |
+
* @type {BasicPreset[]}
|
| 31 |
+
*/
|
| 32 |
+
presets = [];
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* The soundfont's samples
|
| 36 |
+
* @type {BasicSample[]}
|
| 37 |
+
*/
|
| 38 |
+
samples = [];
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* The soundfont's instruments
|
| 42 |
+
* @type {BasicInstrument[]}
|
| 43 |
+
*/
|
| 44 |
+
instruments = [];
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Soundfont's default modulatorss
|
| 48 |
+
* @type {Modulator[]}
|
| 49 |
+
*/
|
| 50 |
+
defaultModulators = defaultModulators.map(m => Modulator.copy(m));
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Checks for XG drumsets and considers if this soundfont is XG.
|
| 54 |
+
* @type {boolean}
|
| 55 |
+
*/
|
| 56 |
+
isXGBank = false;
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Creates a new basic soundfont template
|
| 60 |
+
* @param data {undefined|{presets: BasicPreset[], info: Object<string, string>}}
|
| 61 |
+
*/
|
| 62 |
+
constructor(data = undefined)
|
| 63 |
+
{
|
| 64 |
+
if (data?.presets)
|
| 65 |
+
{
|
| 66 |
+
this.presets.push(...data.presets);
|
| 67 |
+
this.soundFontInfo = data.info;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Merges soundfonts with the given order. Keep in mind that the info read is copied from the first one
|
| 73 |
+
* @param soundfonts {...BasicSoundBank} the soundfonts to merge, the first overwrites the last
|
| 74 |
+
* @returns {BasicSoundBank}
|
| 75 |
+
*/
|
| 76 |
+
static mergeSoundBanks(...soundfonts)
|
| 77 |
+
{
|
| 78 |
+
const mainSf = soundfonts.shift();
|
| 79 |
+
const presets = mainSf.presets;
|
| 80 |
+
while (soundfonts.length)
|
| 81 |
+
{
|
| 82 |
+
const newPresets = soundfonts.shift().presets;
|
| 83 |
+
newPresets.forEach(newPreset =>
|
| 84 |
+
{
|
| 85 |
+
if (
|
| 86 |
+
presets.find(existingPreset => existingPreset.bank === newPreset.bank && existingPreset.program === newPreset.program) === undefined
|
| 87 |
+
)
|
| 88 |
+
{
|
| 89 |
+
presets.push(newPreset);
|
| 90 |
+
}
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return new BasicSoundBank({ presets: presets, info: mainSf.soundFontInfo });
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Creates a simple soundfont with one saw wave preset.
|
| 99 |
+
* @returns {ArrayBufferLike}
|
| 100 |
+
*/
|
| 101 |
+
static getDummySoundfontFile()
|
| 102 |
+
{
|
| 103 |
+
const font = new BasicSoundBank();
|
| 104 |
+
const sample = new BasicSample(
|
| 105 |
+
"Saw",
|
| 106 |
+
44100,
|
| 107 |
+
65,
|
| 108 |
+
20,
|
| 109 |
+
0,
|
| 110 |
+
0,
|
| 111 |
+
0,
|
| 112 |
+
127
|
| 113 |
+
);
|
| 114 |
+
sample.sampleData = new Float32Array(128);
|
| 115 |
+
for (let i = 0; i < 128; i++)
|
| 116 |
+
{
|
| 117 |
+
sample.sampleData[i] = (i / 128) * 2 - 1;
|
| 118 |
+
}
|
| 119 |
+
font.samples.push(sample);
|
| 120 |
+
|
| 121 |
+
const gZone = new BasicInstrumentZone();
|
| 122 |
+
gZone.isGlobal = true;
|
| 123 |
+
gZone.generators.push(new Generator(generatorTypes.initialAttenuation, 375));
|
| 124 |
+
gZone.generators.push(new Generator(generatorTypes.releaseVolEnv, -1000));
|
| 125 |
+
gZone.generators.push(new Generator(generatorTypes.sampleModes, 1));
|
| 126 |
+
|
| 127 |
+
const zone1 = new BasicInstrumentZone();
|
| 128 |
+
zone1.sample = sample;
|
| 129 |
+
|
| 130 |
+
const zone2 = new BasicInstrumentZone();
|
| 131 |
+
zone2.sample = sample;
|
| 132 |
+
zone2.generators.push(new Generator(generatorTypes.fineTune, -9));
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
const inst = new BasicInstrument();
|
| 136 |
+
inst.instrumentName = "Saw Wave";
|
| 137 |
+
inst.instrumentZones.push(gZone);
|
| 138 |
+
inst.instrumentZones.push(zone1);
|
| 139 |
+
inst.instrumentZones.push(zone2);
|
| 140 |
+
font.instruments.push(inst);
|
| 141 |
+
|
| 142 |
+
const pZone = new BasicPresetZone();
|
| 143 |
+
pZone.instrument = inst;
|
| 144 |
+
|
| 145 |
+
const preset = new BasicPreset(font);
|
| 146 |
+
preset.presetName = "Saw Wave";
|
| 147 |
+
preset.presetZones.push(pZone);
|
| 148 |
+
font.presets.push(preset);
|
| 149 |
+
|
| 150 |
+
font.soundFontInfo["ifil"] = "2.1";
|
| 151 |
+
font.soundFontInfo["isng"] = "EMU8000";
|
| 152 |
+
font.soundFontInfo["INAM"] = "Dummy";
|
| 153 |
+
font._parseInternal();
|
| 154 |
+
return font.write().buffer;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* parses the bank after loading is done
|
| 159 |
+
* @protected
|
| 160 |
+
*/
|
| 161 |
+
_parseInternal()
|
| 162 |
+
{
|
| 163 |
+
this.isXGBank = false;
|
| 164 |
+
// definitions for XG:
|
| 165 |
+
// at least one preset with bank 127, 126 or 120
|
| 166 |
+
// MUST be a valid XG bank.
|
| 167 |
+
// allowed banks: (see XG specification)
|
| 168 |
+
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 24,
|
| 169 |
+
// 25, 27, 28, 29, 30, 31, 32, 33, 40, 41, 48, 56, 57, 58,
|
| 170 |
+
// 64, 65, 66, 126, 127
|
| 171 |
+
const allowedPrograms = new Set([
|
| 172 |
+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 24,
|
| 173 |
+
25, 27, 28, 29, 30, 31, 32, 33, 40, 41, 48, 56, 57, 58,
|
| 174 |
+
64, 65, 66, 126, 127
|
| 175 |
+
]);
|
| 176 |
+
for (const preset of this.presets)
|
| 177 |
+
{
|
| 178 |
+
if (isXGDrums(preset.bank))
|
| 179 |
+
{
|
| 180 |
+
this.isXGBank = true;
|
| 181 |
+
if (!allowedPrograms.has(preset.program))
|
| 182 |
+
{
|
| 183 |
+
// not valid!
|
| 184 |
+
this.isXGBank = false;
|
| 185 |
+
SpessaSynthInfo(
|
| 186 |
+
`%cThis bank is not valid XG. Preset %c${preset.bank}:${preset.program}%c is not a valid XG drum. XG mode will use presets on bank 128.`,
|
| 187 |
+
consoleColors.info,
|
| 188 |
+
consoleColors.value,
|
| 189 |
+
consoleColors.info
|
| 190 |
+
);
|
| 191 |
+
break;
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* Trims a sound bank to only contain samples in a given MIDI file
|
| 199 |
+
* @param mid {BasicMIDI} - the MIDI file
|
| 200 |
+
*/
|
| 201 |
+
trimSoundBank(mid)
|
| 202 |
+
{
|
| 203 |
+
const soundfont = this;
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* @param instrument {Instrument}
|
| 207 |
+
* @param combos {{key: number, velocity: number}[]}
|
| 208 |
+
* @returns {number}
|
| 209 |
+
*/
|
| 210 |
+
function trimInstrumentZones(instrument, combos)
|
| 211 |
+
{
|
| 212 |
+
let trimmedIZones = 0;
|
| 213 |
+
for (let iZoneIndex = 0; iZoneIndex < instrument.instrumentZones.length; iZoneIndex++)
|
| 214 |
+
{
|
| 215 |
+
const iZone = instrument.instrumentZones[iZoneIndex];
|
| 216 |
+
if (iZone.isGlobal)
|
| 217 |
+
{
|
| 218 |
+
continue;
|
| 219 |
+
}
|
| 220 |
+
const iKeyRange = iZone.keyRange;
|
| 221 |
+
const iVelRange = iZone.velRange;
|
| 222 |
+
let isIZoneUsed = false;
|
| 223 |
+
for (const iCombo of combos)
|
| 224 |
+
{
|
| 225 |
+
if (
|
| 226 |
+
(iCombo.key >= iKeyRange.min && iCombo.key <= iKeyRange.max) &&
|
| 227 |
+
(iCombo.velocity >= iVelRange.min && iCombo.velocity <= iVelRange.max)
|
| 228 |
+
)
|
| 229 |
+
{
|
| 230 |
+
isIZoneUsed = true;
|
| 231 |
+
break;
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
if (!isIZoneUsed)
|
| 235 |
+
{
|
| 236 |
+
SpessaSynthInfo(
|
| 237 |
+
`%c${iZone.sample.sampleName} %cremoved from %c${instrument.instrumentName}%c. Use count: %c${iZone.useCount - 1}`,
|
| 238 |
+
consoleColors.recognized,
|
| 239 |
+
consoleColors.info,
|
| 240 |
+
consoleColors.recognized,
|
| 241 |
+
consoleColors.info,
|
| 242 |
+
consoleColors.recognized
|
| 243 |
+
);
|
| 244 |
+
if (instrument.safeDeleteZone(iZoneIndex))
|
| 245 |
+
{
|
| 246 |
+
trimmedIZones++;
|
| 247 |
+
iZoneIndex--;
|
| 248 |
+
SpessaSynthInfo(
|
| 249 |
+
`%c${iZone.sample.sampleName} %cdeleted`,
|
| 250 |
+
consoleColors.recognized,
|
| 251 |
+
consoleColors.info
|
| 252 |
+
);
|
| 253 |
+
}
|
| 254 |
+
if (iZone.sample.useCount < 1)
|
| 255 |
+
{
|
| 256 |
+
soundfont.deleteSample(iZone.sample);
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
}
|
| 261 |
+
return trimmedIZones;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
SpessaSynthGroup(
|
| 265 |
+
"%cTrimming soundfont...",
|
| 266 |
+
consoleColors.info
|
| 267 |
+
);
|
| 268 |
+
const usedProgramsAndKeys = mid.getUsedProgramsAndKeys(soundfont);
|
| 269 |
+
|
| 270 |
+
SpessaSynthGroupCollapsed(
|
| 271 |
+
"%cModifying soundfont...",
|
| 272 |
+
consoleColors.info
|
| 273 |
+
);
|
| 274 |
+
SpessaSynthInfo("Detected keys for midi:", usedProgramsAndKeys);
|
| 275 |
+
// modify the soundfont to only include programs and samples that are used
|
| 276 |
+
for (let presetIndex = 0; presetIndex < soundfont.presets.length; presetIndex++)
|
| 277 |
+
{
|
| 278 |
+
const p = soundfont.presets[presetIndex];
|
| 279 |
+
const string = p.bank + ":" + p.program;
|
| 280 |
+
const used = usedProgramsAndKeys[string];
|
| 281 |
+
if (used === undefined)
|
| 282 |
+
{
|
| 283 |
+
SpessaSynthInfo(
|
| 284 |
+
`%cDeleting preset %c${p.presetName}%c and its zones`,
|
| 285 |
+
consoleColors.info,
|
| 286 |
+
consoleColors.recognized,
|
| 287 |
+
consoleColors.info
|
| 288 |
+
);
|
| 289 |
+
soundfont.deletePreset(p);
|
| 290 |
+
presetIndex--;
|
| 291 |
+
}
|
| 292 |
+
else
|
| 293 |
+
{
|
| 294 |
+
const combos = [...used].map(s =>
|
| 295 |
+
{
|
| 296 |
+
const split = s.split("-");
|
| 297 |
+
return {
|
| 298 |
+
key: parseInt(split[0]),
|
| 299 |
+
velocity: parseInt(split[1])
|
| 300 |
+
};
|
| 301 |
+
});
|
| 302 |
+
SpessaSynthGroupCollapsed(
|
| 303 |
+
`%cTrimming %c${p.presetName}`,
|
| 304 |
+
consoleColors.info,
|
| 305 |
+
consoleColors.recognized
|
| 306 |
+
);
|
| 307 |
+
SpessaSynthInfo(`Keys for ${p.presetName}:`, combos);
|
| 308 |
+
let trimmedZones = 0;
|
| 309 |
+
// clean the preset to only use zones that are used
|
| 310 |
+
for (let zoneIndex = 0; zoneIndex < p.presetZones.length; zoneIndex++)
|
| 311 |
+
{
|
| 312 |
+
const zone = p.presetZones[zoneIndex];
|
| 313 |
+
if (zone.isGlobal)
|
| 314 |
+
{
|
| 315 |
+
continue;
|
| 316 |
+
}
|
| 317 |
+
const keyRange = zone.keyRange;
|
| 318 |
+
const velRange = zone.velRange;
|
| 319 |
+
// check if any of the combos matches the zone
|
| 320 |
+
let isZoneUsed = false;
|
| 321 |
+
for (const combo of combos)
|
| 322 |
+
{
|
| 323 |
+
if (
|
| 324 |
+
(combo.key >= keyRange.min && combo.key <= keyRange.max) &&
|
| 325 |
+
(combo.velocity >= velRange.min && combo.velocity <= velRange.max)
|
| 326 |
+
)
|
| 327 |
+
{
|
| 328 |
+
// zone is used, trim the instrument zones
|
| 329 |
+
isZoneUsed = true;
|
| 330 |
+
const trimmedIZones = trimInstrumentZones(zone.instrument, combos);
|
| 331 |
+
SpessaSynthInfo(
|
| 332 |
+
`%cTrimmed off %c${trimmedIZones}%c zones from %c${zone.instrument.instrumentName}`,
|
| 333 |
+
consoleColors.info,
|
| 334 |
+
consoleColors.recognized,
|
| 335 |
+
consoleColors.info,
|
| 336 |
+
consoleColors.recognized
|
| 337 |
+
);
|
| 338 |
+
break;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
if (!isZoneUsed)
|
| 342 |
+
{
|
| 343 |
+
trimmedZones++;
|
| 344 |
+
p.deleteZone(zoneIndex);
|
| 345 |
+
if (zone.instrument.useCount < 1)
|
| 346 |
+
{
|
| 347 |
+
soundfont.deleteInstrument(zone.instrument);
|
| 348 |
+
}
|
| 349 |
+
zoneIndex--;
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
SpessaSynthInfo(
|
| 353 |
+
`%cTrimmed off %c${trimmedZones}%c zones from %c${p.presetName}`,
|
| 354 |
+
consoleColors.info,
|
| 355 |
+
consoleColors.recognized,
|
| 356 |
+
consoleColors.info,
|
| 357 |
+
consoleColors.recognized
|
| 358 |
+
);
|
| 359 |
+
SpessaSynthGroupEnd();
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
soundfont.removeUnusedElements();
|
| 363 |
+
|
| 364 |
+
soundfont.soundFontInfo["ICMT"] = `NOTE: This soundfont was trimmed by SpessaSynth to only contain presets used in "${mid.midiName}"\n\n`
|
| 365 |
+
+ soundfont.soundFontInfo["ICMT"];
|
| 366 |
+
|
| 367 |
+
SpessaSynthInfo(
|
| 368 |
+
"%cSoundfont modified!",
|
| 369 |
+
consoleColors.recognized
|
| 370 |
+
);
|
| 371 |
+
SpessaSynthGroupEnd();
|
| 372 |
+
SpessaSynthGroupEnd();
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
removeUnusedElements()
|
| 376 |
+
{
|
| 377 |
+
this.instruments.forEach(i =>
|
| 378 |
+
{
|
| 379 |
+
if (i.useCount < 1)
|
| 380 |
+
{
|
| 381 |
+
i.instrumentZones.forEach(z =>
|
| 382 |
+
{
|
| 383 |
+
if (!z.isGlobal)
|
| 384 |
+
{
|
| 385 |
+
z.sample.useCount--;
|
| 386 |
+
}
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
});
|
| 390 |
+
this.instruments = this.instruments.filter(i => i.useCount > 0);
|
| 391 |
+
this.samples = this.samples.filter(s => s.useCount > 0);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/**
|
| 395 |
+
* @param instrument {BasicInstrument}
|
| 396 |
+
*/
|
| 397 |
+
deleteInstrument(instrument)
|
| 398 |
+
{
|
| 399 |
+
if (instrument.useCount > 0)
|
| 400 |
+
{
|
| 401 |
+
throw new Error(`Cannot delete an instrument that has ${instrument.useCount} usages.`);
|
| 402 |
+
}
|
| 403 |
+
this.instruments.splice(this.instruments.indexOf(instrument), 1);
|
| 404 |
+
instrument.deleteInstrument();
|
| 405 |
+
this.removeUnusedElements();
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* @param preset {BasicPreset}
|
| 410 |
+
*/
|
| 411 |
+
deletePreset(preset)
|
| 412 |
+
{
|
| 413 |
+
preset.deletePreset();
|
| 414 |
+
this.presets.splice(this.presets.indexOf(preset), 1);
|
| 415 |
+
this.removeUnusedElements();
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
/**
|
| 419 |
+
* @param sample {BasicSample}
|
| 420 |
+
*/
|
| 421 |
+
deleteSample(sample)
|
| 422 |
+
{
|
| 423 |
+
if (sample.useCount > 0)
|
| 424 |
+
{
|
| 425 |
+
throw new Error(`Cannot delete sample that has ${sample.useCount} usages.`);
|
| 426 |
+
}
|
| 427 |
+
this.samples.splice(this.samples.indexOf(sample), 1);
|
| 428 |
+
this.removeUnusedElements();
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/**
|
| 432 |
+
* Get the appropriate preset, undefined if not found
|
| 433 |
+
* @param bankNr {number}
|
| 434 |
+
* @param programNr {number}
|
| 435 |
+
* @param allowXGDrums {boolean} if true, allows XG drum banks (120, 126 and 127) as drum preset
|
| 436 |
+
* @return {BasicPreset}
|
| 437 |
+
*/
|
| 438 |
+
getPresetNoFallback(bankNr, programNr, allowXGDrums = false)
|
| 439 |
+
{
|
| 440 |
+
const isDrum = bankNr === 128 || (allowXGDrums && isXGDrums(bankNr));
|
| 441 |
+
// check for exact match
|
| 442 |
+
let p;
|
| 443 |
+
if (isDrum)
|
| 444 |
+
{
|
| 445 |
+
p = this.presets.find(p => p.bank === bankNr && p.isDrumPreset(allowXGDrums) && p.program === programNr);
|
| 446 |
+
}
|
| 447 |
+
else
|
| 448 |
+
{
|
| 449 |
+
p = this.presets.find(p => p.bank === bankNr && p.program === programNr);
|
| 450 |
+
}
|
| 451 |
+
if (p)
|
| 452 |
+
{
|
| 453 |
+
return p;
|
| 454 |
+
}
|
| 455 |
+
// no match...
|
| 456 |
+
if (isDrum)
|
| 457 |
+
{
|
| 458 |
+
if (allowXGDrums)
|
| 459 |
+
{
|
| 460 |
+
// try any drum preset with matching program?
|
| 461 |
+
const p = this.presets.find(p => p.isDrumPreset(allowXGDrums) && p.program === programNr);
|
| 462 |
+
if (p)
|
| 463 |
+
{
|
| 464 |
+
return p;
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
return undefined;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
/**
|
| 472 |
+
* Get the appropriate preset
|
| 473 |
+
* @param bankNr {number}
|
| 474 |
+
* @param programNr {number}
|
| 475 |
+
* @param allowXGDrums {boolean} if true, allows XG drum banks (120, 126 and 127) as drum preset
|
| 476 |
+
* @returns {BasicPreset}
|
| 477 |
+
*/
|
| 478 |
+
getPreset(bankNr, programNr, allowXGDrums = false)
|
| 479 |
+
{
|
| 480 |
+
const isDrums = bankNr === 128 || (allowXGDrums && isXGDrums(bankNr));
|
| 481 |
+
// check for exact match
|
| 482 |
+
let preset;
|
| 483 |
+
// only allow drums if the preset is considered to be a drum preset
|
| 484 |
+
if (isDrums)
|
| 485 |
+
{
|
| 486 |
+
preset = this.presets.find(p => p.bank === bankNr && p.isDrumPreset(allowXGDrums) && p.program === programNr);
|
| 487 |
+
}
|
| 488 |
+
else
|
| 489 |
+
{
|
| 490 |
+
preset = this.presets.find(p => p.bank === bankNr && p.program === programNr);
|
| 491 |
+
}
|
| 492 |
+
if (preset)
|
| 493 |
+
{
|
| 494 |
+
return preset;
|
| 495 |
+
}
|
| 496 |
+
// no match...
|
| 497 |
+
if (isDrums)
|
| 498 |
+
{
|
| 499 |
+
// drum preset: find any preset with bank 128
|
| 500 |
+
preset = this.presets.find(p => p.isDrumPreset(allowXGDrums) && p.program === programNr);
|
| 501 |
+
if (!preset)
|
| 502 |
+
{
|
| 503 |
+
// only allow 128, otherwise it would default to XG SFX
|
| 504 |
+
preset = this.presets.find(p => p.isDrumPreset(allowXGDrums));
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
else
|
| 508 |
+
{
|
| 509 |
+
// non-drum preset: find any preset with the given program that is not a drum preset
|
| 510 |
+
preset = this.presets.find(p => p.program === programNr && !p.isDrumPreset(allowXGDrums));
|
| 511 |
+
}
|
| 512 |
+
if (preset)
|
| 513 |
+
{
|
| 514 |
+
SpessaSynthWarn(
|
| 515 |
+
`%cPreset ${bankNr}.${programNr} not found. Replaced with %c${preset.presetName} (${preset.bank}.${preset.program})`,
|
| 516 |
+
consoleColors.warn,
|
| 517 |
+
consoleColors.recognized
|
| 518 |
+
);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
// no preset, use the first one available
|
| 522 |
+
if (!preset)
|
| 523 |
+
{
|
| 524 |
+
SpessaSynthWarn(`Preset ${programNr} not found. Defaulting to`, this.presets[0].presetName);
|
| 525 |
+
preset = this.presets[0];
|
| 526 |
+
}
|
| 527 |
+
return preset;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
/**
|
| 531 |
+
* gets preset by name
|
| 532 |
+
* @param presetName {string}
|
| 533 |
+
* @returns {BasicPreset}
|
| 534 |
+
*/
|
| 535 |
+
getPresetByName(presetName)
|
| 536 |
+
{
|
| 537 |
+
let preset = this.presets.find(p => p.presetName === presetName);
|
| 538 |
+
if (!preset)
|
| 539 |
+
{
|
| 540 |
+
SpessaSynthWarn("Preset not found. Defaulting to:", this.presets[0].presetName);
|
| 541 |
+
preset = this.presets[0];
|
| 542 |
+
}
|
| 543 |
+
return preset;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
/**
|
| 547 |
+
* @param error {string}
|
| 548 |
+
*/
|
| 549 |
+
parsingError(error)
|
| 550 |
+
{
|
| 551 |
+
throw new Error(`SF parsing error: ${error} The file may be corrupted.`);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
destroySoundBank()
|
| 555 |
+
{
|
| 556 |
+
delete this.presets;
|
| 557 |
+
delete this.instruments;
|
| 558 |
+
delete this.samples;
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
BasicSoundBank.prototype.write = write;
|
| 563 |
+
BasicSoundBank.prototype.writeDLS = writeDLS;
|
| 564 |
+
|
| 565 |
+
export { BasicSoundBank };
|
spessasynth_lib/soundfont/basic_soundfont/basic_zone.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @typedef {Object} SoundFontRange
|
| 3 |
+
* @property {number} min - the minimum midi note
|
| 4 |
+
* @property {number} max - the maximum midi note
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export class BasicZone
|
| 8 |
+
{
|
| 9 |
+
/**
|
| 10 |
+
* The zone's velocity range
|
| 11 |
+
* min -1 means that it is a default value
|
| 12 |
+
* @type {SoundFontRange}
|
| 13 |
+
*/
|
| 14 |
+
velRange = { min: -1, max: 127 };
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* The zone's key range
|
| 18 |
+
* min -1 means that it is a default value
|
| 19 |
+
* @type {SoundFontRange}
|
| 20 |
+
*/
|
| 21 |
+
keyRange = { min: -1, max: 127 };
|
| 22 |
+
/**
|
| 23 |
+
* Indicates if the zone is global
|
| 24 |
+
* @type {boolean}
|
| 25 |
+
*/
|
| 26 |
+
isGlobal = false;
|
| 27 |
+
/**
|
| 28 |
+
* The zone's generators
|
| 29 |
+
* @type {Generator[]}
|
| 30 |
+
*/
|
| 31 |
+
generators = [];
|
| 32 |
+
/**
|
| 33 |
+
* The zone's modulators
|
| 34 |
+
* @type {Modulator[]}
|
| 35 |
+
*/
|
| 36 |
+
modulators = [];
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* @returns {boolean}
|
| 40 |
+
*/
|
| 41 |
+
get hasKeyRange()
|
| 42 |
+
{
|
| 43 |
+
return this.keyRange.min !== -1;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* @returns {boolean}
|
| 48 |
+
*/
|
| 49 |
+
get hasVelRange()
|
| 50 |
+
{
|
| 51 |
+
return this.velRange.min !== -1;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* @param generatorType {generatorTypes}
|
| 56 |
+
* @param notFoundValue {number}
|
| 57 |
+
* @returns {number}
|
| 58 |
+
*/
|
| 59 |
+
getGeneratorValue(generatorType, notFoundValue)
|
| 60 |
+
{
|
| 61 |
+
return this.generators.find(g => g.generatorType === generatorType)?.generatorValue ?? notFoundValue;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
spessasynth_lib/soundfont/basic_soundfont/basic_zones.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BasicZone } from "./basic_zone.js";
|
| 2 |
+
|
| 3 |
+
export class BasicInstrumentZone extends BasicZone
|
| 4 |
+
{
|
| 5 |
+
/**
|
| 6 |
+
* Zone's sample. Undefined if global
|
| 7 |
+
* @type {BasicSample|undefined}
|
| 8 |
+
*/
|
| 9 |
+
sample = undefined;
|
| 10 |
+
/**
|
| 11 |
+
* The zone's use count
|
| 12 |
+
* @type {number}
|
| 13 |
+
*/
|
| 14 |
+
useCount = 0;
|
| 15 |
+
|
| 16 |
+
deleteZone()
|
| 17 |
+
{
|
| 18 |
+
this.useCount--;
|
| 19 |
+
if (this.isGlobal)
|
| 20 |
+
{
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
this.sample.useCount--;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export class BasicPresetZone extends BasicZone
|
| 28 |
+
{
|
| 29 |
+
/**
|
| 30 |
+
* Zone's instrument. Undefined if global
|
| 31 |
+
* @type {BasicInstrument|undefined}
|
| 32 |
+
*/
|
| 33 |
+
instrument = undefined;
|
| 34 |
+
|
| 35 |
+
deleteZone()
|
| 36 |
+
{
|
| 37 |
+
if (this.isGlobal)
|
| 38 |
+
{
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
this.instrument.removeUseCount();
|
| 42 |
+
}
|
| 43 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/generator.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @enum {number}
|
| 3 |
+
*/
|
| 4 |
+
export const generatorTypes = {
|
| 5 |
+
INVALID: -1, // invalid generator
|
| 6 |
+
startAddrsOffset: 0, // sample control - moves sample start point
|
| 7 |
+
endAddrOffset: 1, // sample control - moves sample end point
|
| 8 |
+
startloopAddrsOffset: 2, // loop control - moves loop start point
|
| 9 |
+
endloopAddrsOffset: 3, // loop control - moves loop end point
|
| 10 |
+
startAddrsCoarseOffset: 4, // sample control - moves sample start point in 32,768 increments
|
| 11 |
+
modLfoToPitch: 5, // pitch modulation - modulation lfo pitch modulation in cents
|
| 12 |
+
vibLfoToPitch: 6, // pitch modulation - vibrato lfo pitch modulation in cents
|
| 13 |
+
modEnvToPitch: 7, // pitch modulation - modulation envelope pitch modulation in cents
|
| 14 |
+
initialFilterFc: 8, // filter - lowpass filter cutoff in cents
|
| 15 |
+
initialFilterQ: 9, // filter - lowpass filter resonance
|
| 16 |
+
modLfoToFilterFc: 10, // filter modulation - modulation lfo lowpass filter cutoff in cents
|
| 17 |
+
modEnvToFilterFc: 11, // filter modulation - modulation envelope lowpass filter cutoff in cents
|
| 18 |
+
endAddrsCoarseOffset: 12, // ample control - move sample end point in 32,768 increments
|
| 19 |
+
modLfoToVolume: 13, // modulation lfo - volume (tremolo), where 100 = 10dB
|
| 20 |
+
unused1: 14, // unused
|
| 21 |
+
chorusEffectsSend: 15, // effect send - how much is sent to chorus 0 - 1000
|
| 22 |
+
reverbEffectsSend: 16, // effect send - how much is sent to reverb 0 - 1000
|
| 23 |
+
pan: 17, // panning - where -500 = left, 0 = center, 500 = right
|
| 24 |
+
unused2: 18, // unused
|
| 25 |
+
unused3: 19, // unused
|
| 26 |
+
unused4: 20, // unused
|
| 27 |
+
delayModLFO: 21, // mod lfo - delay for mod lfo to start from zero
|
| 28 |
+
freqModLFO: 22, // mod lfo - frequency of mod lfo, 0 = 8.176 Hz, units: f => 1200log2(f/8.176)
|
| 29 |
+
delayVibLFO: 23, // vib lfo - delay for vibrato lfo to start from zero
|
| 30 |
+
freqVibLFO: 24, // vib lfo - frequency of vibrato lfo, 0 = 8.176Hz, unit: f => 1200log2(f/8.176)
|
| 31 |
+
delayModEnv: 25, // mod env - 0 = 1 s decay till mod env starts
|
| 32 |
+
attackModEnv: 26, // mod env - attack of mod env
|
| 33 |
+
holdModEnv: 27, // mod env - hold of mod env
|
| 34 |
+
decayModEnv: 28, // mod env - decay of mod env
|
| 35 |
+
sustainModEnv: 29, // mod env - sustain of mod env
|
| 36 |
+
releaseModEnv: 30, // mod env - release of mod env
|
| 37 |
+
keyNumToModEnvHold: 31, // mod env - also modulating mod envelope hold with key number
|
| 38 |
+
keyNumToModEnvDecay: 32, // mod env - also modulating mod envelope decay with key number
|
| 39 |
+
delayVolEnv: 33, // vol env - delay of envelope from zero (weird scale)
|
| 40 |
+
attackVolEnv: 34, // vol env - attack of envelope
|
| 41 |
+
holdVolEnv: 35, // vol env - hold of envelope
|
| 42 |
+
decayVolEnv: 36, // vol env - decay of envelope
|
| 43 |
+
sustainVolEnv: 37, // vol env - sustain of envelope
|
| 44 |
+
releaseVolEnv: 38, // vol env - release of envelope
|
| 45 |
+
keyNumToVolEnvHold: 39, // vol env - key number to volume envelope hold
|
| 46 |
+
keyNumToVolEnvDecay: 40, // vol env - key number to volume envelope decay
|
| 47 |
+
instrument: 41, // zone - instrument index to use for preset zone
|
| 48 |
+
reserved1: 42, // reserved
|
| 49 |
+
keyRange: 43, // zone - key range for which preset / instrument zone is active
|
| 50 |
+
velRange: 44, // zone - velocity range for which preset / instrument zone is active
|
| 51 |
+
startloopAddrsCoarseOffset: 45, // sample control - moves sample loop start point in 32,768 increments
|
| 52 |
+
keyNum: 46, // zone - instrument only: always use this midi number (ignore what's pressed)
|
| 53 |
+
velocity: 47, // zone - instrument only: always use this velocity (ignore what's pressed)
|
| 54 |
+
initialAttenuation: 48, // zone - allows turning down the volume, 10 = -1dB
|
| 55 |
+
reserved2: 49, // reserved
|
| 56 |
+
endloopAddrsCoarseOffset: 50, // sample control - moves sample loop end point in 32,768 increments
|
| 57 |
+
coarseTune: 51, // tune - pitch offset in semitones
|
| 58 |
+
fineTune: 52, // tune - pitch offset in cents
|
| 59 |
+
sampleID: 53, // sample - instrument zone only: which sample to use
|
| 60 |
+
sampleModes: 54, // sample - 0 = no loop, 1 = loop, 2 = reserved, 3 = loop and play till the end in release phase
|
| 61 |
+
reserved3: 55, // reserved
|
| 62 |
+
scaleTuning: 56, // sample - the degree to which MIDI key number influences pitch, 100 = default
|
| 63 |
+
exclusiveClass: 57, // sample - = cut = choke group
|
| 64 |
+
overridingRootKey: 58, // sample - can override the sample's original pitch
|
| 65 |
+
unused5: 59, // unused
|
| 66 |
+
endOper: 60 // end marker
|
| 67 |
+
};
|
| 68 |
+
/**
|
| 69 |
+
* @type {{min: number, max: number, def: number}[]}
|
| 70 |
+
*/
|
| 71 |
+
export const generatorLimits = [];
|
| 72 |
+
// offsets
|
| 73 |
+
generatorLimits[generatorTypes.startAddrsOffset] = { min: 0, max: 32768, def: 0 };
|
| 74 |
+
generatorLimits[generatorTypes.endAddrOffset] = { min: -32768, max: 32768, def: 0 };
|
| 75 |
+
generatorLimits[generatorTypes.startloopAddrsOffset] = { min: -32768, max: 32768, def: 0 };
|
| 76 |
+
generatorLimits[generatorTypes.endloopAddrsOffset] = { min: -32768, max: 32768, def: 0 };
|
| 77 |
+
generatorLimits[generatorTypes.startAddrsCoarseOffset] = { min: 0, max: 32768, def: 0 };
|
| 78 |
+
|
| 79 |
+
// pitch influence
|
| 80 |
+
generatorLimits[generatorTypes.modLfoToPitch] = { min: -12000, max: 12000, def: 0 };
|
| 81 |
+
generatorLimits[generatorTypes.vibLfoToPitch] = { min: -12000, max: 12000, def: 0 };
|
| 82 |
+
generatorLimits[generatorTypes.modEnvToPitch] = { min: -12000, max: 12000, def: 0 };
|
| 83 |
+
|
| 84 |
+
// lowpass
|
| 85 |
+
generatorLimits[generatorTypes.initialFilterFc] = { min: 1500, max: 13500, def: 13500 };
|
| 86 |
+
generatorLimits[generatorTypes.initialFilterQ] = { min: 0, max: 960, def: 0 };
|
| 87 |
+
generatorLimits[generatorTypes.modLfoToFilterFc] = { min: -12000, max: 12000, def: 0 };
|
| 88 |
+
generatorLimits[generatorTypes.modEnvToFilterFc] = { min: -12000, max: 12000, def: 0 };
|
| 89 |
+
|
| 90 |
+
generatorLimits[generatorTypes.endAddrsCoarseOffset] = { min: -32768, max: 32768, def: 0 };
|
| 91 |
+
|
| 92 |
+
generatorLimits[generatorTypes.modLfoToVolume] = { min: -960, max: 960, def: 0 };
|
| 93 |
+
|
| 94 |
+
// effects, pan
|
| 95 |
+
generatorLimits[generatorTypes.chorusEffectsSend] = { min: 0, max: 1000, def: 0 };
|
| 96 |
+
generatorLimits[generatorTypes.reverbEffectsSend] = { min: 0, max: 1000, def: 0 };
|
| 97 |
+
generatorLimits[generatorTypes.pan] = { min: -500, max: 500, def: 0 };
|
| 98 |
+
|
| 99 |
+
// lfo
|
| 100 |
+
generatorLimits[generatorTypes.delayModLFO] = { min: -12000, max: 5000, def: -12000 };
|
| 101 |
+
generatorLimits[generatorTypes.freqModLFO] = { min: -16000, max: 4500, def: 0 };
|
| 102 |
+
generatorLimits[generatorTypes.delayVibLFO] = { min: -12000, max: 5000, def: -12000 };
|
| 103 |
+
generatorLimits[generatorTypes.freqVibLFO] = { min: -16000, max: 4500, def: 0 };
|
| 104 |
+
|
| 105 |
+
// mod env
|
| 106 |
+
generatorLimits[generatorTypes.delayModEnv] = { min: -32768, max: 5000, def: -32768 }; // -32,768 indicates instant phase,
|
| 107 |
+
// this is done to prevent click at the start of filter modenv
|
| 108 |
+
generatorLimits[generatorTypes.attackModEnv] = { min: -32768, max: 8000, def: -32768 };
|
| 109 |
+
generatorLimits[generatorTypes.holdModEnv] = { min: -12000, max: 5000, def: -12000 };
|
| 110 |
+
generatorLimits[generatorTypes.decayModEnv] = { min: -12000, max: 8000, def: -12000 };
|
| 111 |
+
generatorLimits[generatorTypes.sustainModEnv] = { min: 0, max: 1000, def: 0 };
|
| 112 |
+
generatorLimits[generatorTypes.releaseModEnv] = { min: -7200, max: 8000, def: -12000 }; // min is set to -7200 to prevent lowpass clicks
|
| 113 |
+
// key num to mod env
|
| 114 |
+
generatorLimits[generatorTypes.keyNumToModEnvHold] = { min: -1200, max: 1200, def: 0 };
|
| 115 |
+
generatorLimits[generatorTypes.keyNumToModEnvDecay] = { min: -1200, max: 1200, def: 0 };
|
| 116 |
+
|
| 117 |
+
// vol env
|
| 118 |
+
generatorLimits[generatorTypes.delayVolEnv] = { min: -12000, max: 5000, def: -12000 };
|
| 119 |
+
generatorLimits[generatorTypes.attackVolEnv] = { min: -12000, max: 8000, def: -12000 };
|
| 120 |
+
generatorLimits[generatorTypes.holdVolEnv] = { min: -12000, max: 5000, def: -12000 };
|
| 121 |
+
generatorLimits[generatorTypes.decayVolEnv] = { min: -12000, max: 8000, def: -12000 };
|
| 122 |
+
generatorLimits[generatorTypes.sustainVolEnv] = { min: 0, max: 1440, def: 0 };
|
| 123 |
+
generatorLimits[generatorTypes.releaseVolEnv] = { min: -7200, max: 8000, def: -12000 }; // min is set to -7200 prevent clicks
|
| 124 |
+
// key num to vol env
|
| 125 |
+
generatorLimits[generatorTypes.keyNumToVolEnvHold] = { min: -1200, max: 1200, def: 0 };
|
| 126 |
+
generatorLimits[generatorTypes.keyNumToVolEnvDecay] = { min: -1200, max: 1200, def: 0 };
|
| 127 |
+
|
| 128 |
+
generatorLimits[generatorTypes.startloopAddrsCoarseOffset] = { min: -32768, max: 32768, def: 0 };
|
| 129 |
+
generatorLimits[generatorTypes.keyNum] = { min: -1, max: 127, def: -1 };
|
| 130 |
+
generatorLimits[generatorTypes.velocity] = { min: -1, max: 127, def: -1 };
|
| 131 |
+
|
| 132 |
+
generatorLimits[generatorTypes.initialAttenuation] = { min: 0, max: 1440, def: 0 };
|
| 133 |
+
|
| 134 |
+
generatorLimits[generatorTypes.endloopAddrsCoarseOffset] = { min: -32768, max: 32768, def: 0 };
|
| 135 |
+
|
| 136 |
+
generatorLimits[generatorTypes.coarseTune] = { min: -120, max: 120, def: 0 };
|
| 137 |
+
generatorLimits[generatorTypes.fineTune] = { min: -12700, max: 12700, def: 0 }; // this generator is used as initial pitch, hence this range
|
| 138 |
+
generatorLimits[generatorTypes.scaleTuning] = { min: 0, max: 1200, def: 100 };
|
| 139 |
+
generatorLimits[generatorTypes.exclusiveClass] = { min: 0, max: 99999, def: 0 };
|
| 140 |
+
generatorLimits[generatorTypes.overridingRootKey] = { min: 0 - 1, max: 127, def: -1 };
|
| 141 |
+
generatorLimits[generatorTypes.sampleModes] = { min: 0, max: 3, def: 0 };
|
| 142 |
+
|
| 143 |
+
export class Generator
|
| 144 |
+
{
|
| 145 |
+
/**
|
| 146 |
+
* The generator's enum number
|
| 147 |
+
* @type {generatorTypes|number}
|
| 148 |
+
*/
|
| 149 |
+
generatorType = generatorTypes.INVALID;
|
| 150 |
+
/**
|
| 151 |
+
* The generator's 16-bit value
|
| 152 |
+
* @type {number}
|
| 153 |
+
*/
|
| 154 |
+
generatorValue = 0;
|
| 155 |
+
|
| 156 |
+
/**
|
| 157 |
+
* Constructs a new generator
|
| 158 |
+
* @param type {generatorTypes|number}
|
| 159 |
+
* @param value {number}
|
| 160 |
+
* @param validate {boolean}
|
| 161 |
+
*/
|
| 162 |
+
constructor(type = generatorTypes.INVALID, value = 0, validate = true)
|
| 163 |
+
{
|
| 164 |
+
this.generatorType = type;
|
| 165 |
+
if (value === undefined)
|
| 166 |
+
{
|
| 167 |
+
throw new Error("No value provided.");
|
| 168 |
+
}
|
| 169 |
+
this.generatorValue = Math.round(value);
|
| 170 |
+
if (validate)
|
| 171 |
+
{
|
| 172 |
+
const lim = generatorLimits[type];
|
| 173 |
+
|
| 174 |
+
if (lim !== undefined)
|
| 175 |
+
{
|
| 176 |
+
this.generatorValue = Math.max(lim.min, Math.min(lim.max, this.generatorValue));
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* generator.js
|
| 184 |
+
* purpose: contains enums for generators,
|
| 185 |
+
* and their limis parses reads soundfont generators, sums them and applies limits
|
| 186 |
+
*/
|
| 187 |
+
/**
|
| 188 |
+
* @param generatorType {number}
|
| 189 |
+
* @param presetGens {Generator[]}
|
| 190 |
+
* @param instrumentGens {Generator[]}
|
| 191 |
+
*/
|
| 192 |
+
export function addAndClampGenerator(generatorType, presetGens, instrumentGens)
|
| 193 |
+
{
|
| 194 |
+
const limits = generatorLimits[generatorType] || { min: 0, max: 32768, def: 0 };
|
| 195 |
+
let presetGen = presetGens.find(g => g.generatorType === generatorType);
|
| 196 |
+
let presetValue = 0;
|
| 197 |
+
if (presetGen)
|
| 198 |
+
{
|
| 199 |
+
presetValue = presetGen.generatorValue;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
let instruGen = instrumentGens.find(g => g.generatorType === generatorType);
|
| 203 |
+
let instruValue = limits.def;
|
| 204 |
+
if (instruGen)
|
| 205 |
+
{
|
| 206 |
+
instruValue = instruGen.generatorValue;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
let value = instruValue + presetValue;
|
| 210 |
+
|
| 211 |
+
// Special case, initial attenuation.
|
| 212 |
+
// Shall get clamped in the volume envelope,
|
| 213 |
+
// so the modulators can be affected by negative generators (the "Brass" patch was problematic...)
|
| 214 |
+
if (generatorType === generatorTypes.initialAttenuation)
|
| 215 |
+
{
|
| 216 |
+
return value;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return Math.max(limits.min, Math.min(limits.max, value));
|
| 220 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/modulator.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generatorTypes } from "./generator.js";
|
| 2 |
+
import { midiControllers } from "../../midi_parser/midi_message.js";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* modulators.js
|
| 6 |
+
* purpose: parses soundfont modulators and the source enums, also includes the default modulators list
|
| 7 |
+
**/
|
| 8 |
+
|
| 9 |
+
export const modulatorSources = {
|
| 10 |
+
noController: 0,
|
| 11 |
+
noteOnVelocity: 2,
|
| 12 |
+
noteOnKeyNum: 3,
|
| 13 |
+
polyPressure: 10,
|
| 14 |
+
channelPressure: 13,
|
| 15 |
+
pitchWheel: 14,
|
| 16 |
+
pitchWheelRange: 16,
|
| 17 |
+
link: 127
|
| 18 |
+
|
| 19 |
+
};
|
| 20 |
+
export const modulatorCurveTypes = {
|
| 21 |
+
linear: 0,
|
| 22 |
+
concave: 1,
|
| 23 |
+
convex: 2,
|
| 24 |
+
switch: 3
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export class Modulator
|
| 28 |
+
{
|
| 29 |
+
/**
|
| 30 |
+
* The current computed value of this modulator
|
| 31 |
+
* @type {number}
|
| 32 |
+
*/
|
| 33 |
+
currentValue = 0;
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* The source enumeration for this modulator
|
| 37 |
+
* @type {number}
|
| 38 |
+
*/
|
| 39 |
+
sourceEnum;
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* The secondary source enumeration for this modulator
|
| 43 |
+
* @type {number}
|
| 44 |
+
*/
|
| 45 |
+
secondarySourceEnum;
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* The generator destination of this modulator
|
| 49 |
+
* @type {generatorTypes}
|
| 50 |
+
*/
|
| 51 |
+
modulatorDestination;
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* The transform amount for this modulator
|
| 55 |
+
* @type {number}
|
| 56 |
+
*/
|
| 57 |
+
transformAmount;
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* The transform type for this modulator
|
| 61 |
+
* @type {0|2}
|
| 62 |
+
*/
|
| 63 |
+
transformType;
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* creates a modulator
|
| 67 |
+
* @param srcEnum {number}
|
| 68 |
+
* @param secSrcEnum {number}
|
| 69 |
+
* @param destination {generatorTypes|number}
|
| 70 |
+
* @param amount {number}
|
| 71 |
+
* @param transformType {number}
|
| 72 |
+
*/
|
| 73 |
+
constructor(srcEnum, secSrcEnum, destination, amount, transformType)
|
| 74 |
+
{
|
| 75 |
+
this.sourceEnum = srcEnum;
|
| 76 |
+
this.modulatorDestination = destination;
|
| 77 |
+
this.secondarySourceEnum = secSrcEnum;
|
| 78 |
+
this.transformAmount = amount;
|
| 79 |
+
this.transformType = transformType;
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if (this.modulatorDestination > 58)
|
| 83 |
+
{
|
| 84 |
+
this.modulatorDestination = generatorTypes.INVALID; // flag as invalid (for linked ones)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// decode the source
|
| 88 |
+
this.sourcePolarity = this.sourceEnum >> 9 & 1;
|
| 89 |
+
this.sourceDirection = this.sourceEnum >> 8 & 1;
|
| 90 |
+
this.sourceUsesCC = this.sourceEnum >> 7 & 1;
|
| 91 |
+
this.sourceIndex = this.sourceEnum & 127;
|
| 92 |
+
this.sourceCurveType = this.sourceEnum >> 10 & 3;
|
| 93 |
+
|
| 94 |
+
// decode the secondary source
|
| 95 |
+
this.secSrcPolarity = this.secondarySourceEnum >> 9 & 1;
|
| 96 |
+
this.secSrcDirection = this.secondarySourceEnum >> 8 & 1;
|
| 97 |
+
this.secSrcUsesCC = this.secondarySourceEnum >> 7 & 1;
|
| 98 |
+
this.secSrcIndex = this.secondarySourceEnum & 127;
|
| 99 |
+
this.secSrcCurveType = this.secondarySourceEnum >> 10 & 3;
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Indicates if the given modulator is chorus or reverb effects modulator.
|
| 103 |
+
* This is done to simulate BASSMIDI effects behavior:
|
| 104 |
+
* - defaults to 1000 transform amount rather than 200
|
| 105 |
+
* - values can be changed, but anything above 200 is 1000
|
| 106 |
+
* (except for values above 1000, they are copied directly)
|
| 107 |
+
* - all values below are multiplied by 5 (200 * 5 = 1000)
|
| 108 |
+
* - still can be disabled if the soundfont has its own modulator curve
|
| 109 |
+
* - this fixes the very low amount of reverb by default and doesn't break soundfonts
|
| 110 |
+
* @type {boolean}
|
| 111 |
+
*/
|
| 112 |
+
this.isEffectModulator =
|
| 113 |
+
(
|
| 114 |
+
this.sourceEnum === 0x00DB
|
| 115 |
+
|| this.sourceEnum === 0x00DD
|
| 116 |
+
)
|
| 117 |
+
&& this.secondarySourceEnum === 0x0
|
| 118 |
+
&& (
|
| 119 |
+
this.modulatorDestination === generatorTypes.reverbEffectsSend
|
| 120 |
+
|| this.modulatorDestination === generatorTypes.chorusEffectsSend
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* @param modulator {Modulator}
|
| 126 |
+
* @returns {Modulator}
|
| 127 |
+
*/
|
| 128 |
+
static copy(modulator)
|
| 129 |
+
{
|
| 130 |
+
return new Modulator(
|
| 131 |
+
modulator.sourceEnum,
|
| 132 |
+
modulator.secondarySourceEnum,
|
| 133 |
+
modulator.modulatorDestination,
|
| 134 |
+
modulator.transformAmount,
|
| 135 |
+
modulator.transformType
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* @param mod1 {Modulator}
|
| 141 |
+
* @param mod2 {Modulator}
|
| 142 |
+
* @param checkAmount {boolean}
|
| 143 |
+
* @returns {boolean}
|
| 144 |
+
*/
|
| 145 |
+
static isIdentical(mod1, mod2, checkAmount = false)
|
| 146 |
+
{
|
| 147 |
+
return (mod1.sourceEnum === mod2.sourceEnum)
|
| 148 |
+
&& (mod1.modulatorDestination === mod2.modulatorDestination)
|
| 149 |
+
&& (mod1.secondarySourceEnum === mod2.secondarySourceEnum)
|
| 150 |
+
&& (mod1.transformType === mod2.transformType)
|
| 151 |
+
&& (!checkAmount || (mod1.transformAmount === mod2.transformAmount));
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* @param mod {Modulator}
|
| 156 |
+
* @returns {string}
|
| 157 |
+
*/
|
| 158 |
+
static debugString(mod)
|
| 159 |
+
{
|
| 160 |
+
function getKeyByValue(object, value)
|
| 161 |
+
{
|
| 162 |
+
return Object.keys(object).find(key => object[key] === value);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
let sourceString = getKeyByValue(modulatorCurveTypes, mod.sourceCurveType);
|
| 166 |
+
sourceString += mod.sourcePolarity === 0 ? " unipolar " : " bipolar ";
|
| 167 |
+
sourceString += mod.sourceDirection === 0 ? "forwards " : "backwards ";
|
| 168 |
+
if (mod.sourceUsesCC)
|
| 169 |
+
{
|
| 170 |
+
sourceString += getKeyByValue(midiControllers, mod.sourceIndex);
|
| 171 |
+
}
|
| 172 |
+
else
|
| 173 |
+
{
|
| 174 |
+
sourceString += getKeyByValue(modulatorSources, mod.sourceIndex);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
let secSrcString = getKeyByValue(modulatorCurveTypes, mod.secSrcCurveType);
|
| 178 |
+
secSrcString += mod.secSrcPolarity === 0 ? " unipolar " : " bipolar ";
|
| 179 |
+
secSrcString += mod.secSrcCurveType === 0 ? "forwards " : "backwards ";
|
| 180 |
+
if (mod.secSrcUsesCC)
|
| 181 |
+
{
|
| 182 |
+
secSrcString += getKeyByValue(midiControllers, mod.secSrcIndex);
|
| 183 |
+
}
|
| 184 |
+
else
|
| 185 |
+
{
|
| 186 |
+
secSrcString += getKeyByValue(modulatorSources, mod.secSrcIndex);
|
| 187 |
+
}
|
| 188 |
+
return `Modulator:
|
| 189 |
+
Source: ${sourceString}
|
| 190 |
+
Secondary source: ${secSrcString}
|
| 191 |
+
Destination: ${getKeyByValue(generatorTypes, mod.modulatorDestination)}
|
| 192 |
+
Trasform amount: ${mod.transformAmount}
|
| 193 |
+
Transform type: ${mod.transformType}
|
| 194 |
+
\n\n`;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* Sum transform and create a NEW modulator
|
| 199 |
+
* @param modulator {Modulator}
|
| 200 |
+
* @returns {Modulator}
|
| 201 |
+
*/
|
| 202 |
+
sumTransform(modulator)
|
| 203 |
+
{
|
| 204 |
+
return new Modulator(
|
| 205 |
+
this.sourceEnum,
|
| 206 |
+
this.secondarySourceEnum,
|
| 207 |
+
this.modulatorDestination,
|
| 208 |
+
this.transformAmount + modulator.transformAmount,
|
| 209 |
+
this.transformType
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
export const DEFAULT_ATTENUATION_MOD_AMOUNT = 960;
|
| 215 |
+
export const DEFAULT_ATTENUATION_MOD_CURVE_TYPE = modulatorCurveTypes.concave;
|
| 216 |
+
|
| 217 |
+
export function getModSourceEnum(curveType, polarity, direction, isCC, index)
|
| 218 |
+
{
|
| 219 |
+
return (curveType << 10) | (polarity << 9) | (direction << 8) | (isCC << 7) | index;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const soundFontModulators = [
|
| 223 |
+
// vel to attenuation
|
| 224 |
+
new Modulator(
|
| 225 |
+
getModSourceEnum(
|
| 226 |
+
DEFAULT_ATTENUATION_MOD_CURVE_TYPE,
|
| 227 |
+
0,
|
| 228 |
+
1,
|
| 229 |
+
0,
|
| 230 |
+
modulatorSources.noteOnVelocity
|
| 231 |
+
),
|
| 232 |
+
0x0,
|
| 233 |
+
generatorTypes.initialAttenuation,
|
| 234 |
+
DEFAULT_ATTENUATION_MOD_AMOUNT,
|
| 235 |
+
0
|
| 236 |
+
),
|
| 237 |
+
|
| 238 |
+
// mod wheel to vibrato
|
| 239 |
+
new Modulator(0x0081, 0x0, generatorTypes.vibLfoToPitch, 50, 0),
|
| 240 |
+
|
| 241 |
+
// vol to attenuation
|
| 242 |
+
new Modulator(
|
| 243 |
+
getModSourceEnum(
|
| 244 |
+
DEFAULT_ATTENUATION_MOD_CURVE_TYPE,
|
| 245 |
+
0,
|
| 246 |
+
1,
|
| 247 |
+
1,
|
| 248 |
+
midiControllers.mainVolume
|
| 249 |
+
),
|
| 250 |
+
0x0,
|
| 251 |
+
generatorTypes.initialAttenuation,
|
| 252 |
+
DEFAULT_ATTENUATION_MOD_AMOUNT,
|
| 253 |
+
0
|
| 254 |
+
),
|
| 255 |
+
|
| 256 |
+
// channel pressure to vibrato
|
| 257 |
+
new Modulator(0x000D, 0x0, generatorTypes.vibLfoToPitch, 50, 0),
|
| 258 |
+
|
| 259 |
+
// pitch wheel to tuning
|
| 260 |
+
new Modulator(0x020E, 0x0010, generatorTypes.fineTune, 12700, 0),
|
| 261 |
+
|
| 262 |
+
// pan to uhh, pan
|
| 263 |
+
// amount is 500 instead of 1000, see #59
|
| 264 |
+
new Modulator(0x028A, 0x0, generatorTypes.pan, 500, 0),
|
| 265 |
+
|
| 266 |
+
// expression to attenuation
|
| 267 |
+
new Modulator(
|
| 268 |
+
getModSourceEnum(
|
| 269 |
+
DEFAULT_ATTENUATION_MOD_CURVE_TYPE,
|
| 270 |
+
0,
|
| 271 |
+
1,
|
| 272 |
+
1,
|
| 273 |
+
midiControllers.expressionController
|
| 274 |
+
),
|
| 275 |
+
0x0,
|
| 276 |
+
generatorTypes.initialAttenuation,
|
| 277 |
+
DEFAULT_ATTENUATION_MOD_AMOUNT,
|
| 278 |
+
0
|
| 279 |
+
),
|
| 280 |
+
|
| 281 |
+
// reverb effects to send
|
| 282 |
+
new Modulator(0x00DB, 0x0, generatorTypes.reverbEffectsSend, 200, 0),
|
| 283 |
+
|
| 284 |
+
// chorus effects to send
|
| 285 |
+
new Modulator(0x00DD, 0x0, generatorTypes.chorusEffectsSend, 200, 0)
|
| 286 |
+
];
|
| 287 |
+
|
| 288 |
+
const customModulators = [
|
| 289 |
+
// custom modulators heck yeah
|
| 290 |
+
// poly pressure to vibrato
|
| 291 |
+
new Modulator(
|
| 292 |
+
getModSourceEnum(modulatorCurveTypes.linear, 0, 0, 0, modulatorSources.polyPressure),
|
| 293 |
+
0x0,
|
| 294 |
+
generatorTypes.vibLfoToPitch,
|
| 295 |
+
50,
|
| 296 |
+
0
|
| 297 |
+
),
|
| 298 |
+
|
| 299 |
+
// cc 92 (tremolo) to modLFO volume
|
| 300 |
+
new Modulator(
|
| 301 |
+
getModSourceEnum(
|
| 302 |
+
modulatorCurveTypes.linear,
|
| 303 |
+
0,
|
| 304 |
+
0,
|
| 305 |
+
1,
|
| 306 |
+
midiControllers.tremoloDepth
|
| 307 |
+
), /*linear forward unipolar cc 92 */
|
| 308 |
+
0x0, // no controller
|
| 309 |
+
generatorTypes.modLfoToVolume,
|
| 310 |
+
24,
|
| 311 |
+
0
|
| 312 |
+
),
|
| 313 |
+
|
| 314 |
+
// cc 73 (attack time) to volEnv attack
|
| 315 |
+
new Modulator(
|
| 316 |
+
getModSourceEnum(
|
| 317 |
+
modulatorCurveTypes.convex,
|
| 318 |
+
1,
|
| 319 |
+
0,
|
| 320 |
+
1,
|
| 321 |
+
midiControllers.attackTime
|
| 322 |
+
), // linear forward bipolar cc 72
|
| 323 |
+
0x0, // no controller
|
| 324 |
+
generatorTypes.attackVolEnv,
|
| 325 |
+
6000,
|
| 326 |
+
0
|
| 327 |
+
),
|
| 328 |
+
|
| 329 |
+
// cc 72 (release time) to volEnv release
|
| 330 |
+
new Modulator(
|
| 331 |
+
getModSourceEnum(
|
| 332 |
+
modulatorCurveTypes.linear,
|
| 333 |
+
1,
|
| 334 |
+
0,
|
| 335 |
+
1,
|
| 336 |
+
midiControllers.releaseTime
|
| 337 |
+
), // linear forward bipolar cc 72
|
| 338 |
+
0x0, // no controller
|
| 339 |
+
generatorTypes.releaseVolEnv,
|
| 340 |
+
3600,
|
| 341 |
+
0
|
| 342 |
+
),
|
| 343 |
+
|
| 344 |
+
// cc 74 (brightness) to filterFc
|
| 345 |
+
new Modulator(
|
| 346 |
+
getModSourceEnum(
|
| 347 |
+
modulatorCurveTypes.linear,
|
| 348 |
+
1,
|
| 349 |
+
0,
|
| 350 |
+
1,
|
| 351 |
+
midiControllers.brightness
|
| 352 |
+
), // linear forwards bipolar cc 74
|
| 353 |
+
0x0, // no controller
|
| 354 |
+
generatorTypes.initialFilterFc,
|
| 355 |
+
6000,
|
| 356 |
+
0
|
| 357 |
+
),
|
| 358 |
+
|
| 359 |
+
// cc 71 (filter Q) to filter Q
|
| 360 |
+
new Modulator(
|
| 361 |
+
getModSourceEnum(
|
| 362 |
+
modulatorCurveTypes.linear,
|
| 363 |
+
1,
|
| 364 |
+
0,
|
| 365 |
+
1,
|
| 366 |
+
midiControllers.filterResonance
|
| 367 |
+
), // linear forwards bipolar cc 74
|
| 368 |
+
0x0, // no controller
|
| 369 |
+
generatorTypes.initialFilterQ,
|
| 370 |
+
250,
|
| 371 |
+
0
|
| 372 |
+
)
|
| 373 |
+
];
|
| 374 |
+
|
| 375 |
+
/**
|
| 376 |
+
* @type {Modulator[]}
|
| 377 |
+
*/
|
| 378 |
+
export const defaultModulators = soundFontModulators.concat(customModulators);
|
spessasynth_lib/soundfont/basic_soundfont/riff_chunk.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IndexedByteArray } from "../../utils/indexed_array.js";
|
| 2 |
+
import { readLittleEndian, writeDword } from "../../utils/byte_functions/little_endian.js";
|
| 3 |
+
import { readBytesAsString, writeStringAsBytes } from "../../utils/byte_functions/string.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* riff_chunk.js
|
| 7 |
+
* reads a riff read and stores it as a class
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export class RiffChunk
|
| 11 |
+
{
|
| 12 |
+
/**
|
| 13 |
+
* Creates a new riff read
|
| 14 |
+
* @constructor
|
| 15 |
+
* @param header {string}
|
| 16 |
+
* @param size {number}
|
| 17 |
+
* @param data {IndexedByteArray}
|
| 18 |
+
*/
|
| 19 |
+
constructor(header, size, data)
|
| 20 |
+
{
|
| 21 |
+
this.header = header;
|
| 22 |
+
this.size = size;
|
| 23 |
+
this.chunkData = data;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* @param dataArray {IndexedByteArray}
|
| 30 |
+
* @param readData {boolean}
|
| 31 |
+
* @param forceShift {boolean}
|
| 32 |
+
* @returns {RiffChunk}
|
| 33 |
+
*/
|
| 34 |
+
export function readRIFFChunk(dataArray, readData = true, forceShift = false)
|
| 35 |
+
{
|
| 36 |
+
let header = readBytesAsString(dataArray, 4);
|
| 37 |
+
|
| 38 |
+
let size = readLittleEndian(dataArray, 4);
|
| 39 |
+
let chunkData = undefined;
|
| 40 |
+
if (readData)
|
| 41 |
+
{
|
| 42 |
+
chunkData = new IndexedByteArray(dataArray.buffer.slice(dataArray.currentIndex, dataArray.currentIndex + size));
|
| 43 |
+
}
|
| 44 |
+
if (readData || forceShift)
|
| 45 |
+
{
|
| 46 |
+
dataArray.currentIndex += size;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (size % 2 !== 0)
|
| 50 |
+
{
|
| 51 |
+
if (dataArray[dataArray.currentIndex] === 0)
|
| 52 |
+
{
|
| 53 |
+
dataArray.currentIndex++;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return new RiffChunk(header, size, chunkData);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* @param chunk {RiffChunk}
|
| 62 |
+
* @param prepend {IndexedByteArray}
|
| 63 |
+
* @returns {IndexedByteArray}
|
| 64 |
+
*/
|
| 65 |
+
export function writeRIFFChunk(chunk, prepend = undefined)
|
| 66 |
+
{
|
| 67 |
+
let size = 8 + chunk.size;
|
| 68 |
+
if (chunk.size % 2 !== 0)
|
| 69 |
+
{
|
| 70 |
+
size++;
|
| 71 |
+
}
|
| 72 |
+
if (prepend)
|
| 73 |
+
{
|
| 74 |
+
size += prepend.length;
|
| 75 |
+
}
|
| 76 |
+
const array = new IndexedByteArray(size);
|
| 77 |
+
// prepend data (for example, type before the read)
|
| 78 |
+
if (prepend)
|
| 79 |
+
{
|
| 80 |
+
array.set(prepend, array.currentIndex);
|
| 81 |
+
array.currentIndex += prepend.length;
|
| 82 |
+
}
|
| 83 |
+
// write header
|
| 84 |
+
writeStringAsBytes(array, chunk.header);
|
| 85 |
+
// write size (excluding header and the size itself) and then prepend if specified
|
| 86 |
+
writeDword(array, size - 8 - (prepend?.length || 0));
|
| 87 |
+
// write data
|
| 88 |
+
array.set(chunk.chunkData, array.currentIndex);
|
| 89 |
+
return array;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* @param header {string}
|
| 94 |
+
* @param data {Uint8Array}
|
| 95 |
+
* @param addZeroByte {Boolean}
|
| 96 |
+
* @param isList {boolean}
|
| 97 |
+
* @returns {IndexedByteArray}
|
| 98 |
+
*/
|
| 99 |
+
export function writeRIFFOddSize(header, data, addZeroByte = false, isList = false)
|
| 100 |
+
{
|
| 101 |
+
if (addZeroByte)
|
| 102 |
+
{
|
| 103 |
+
const tempData = new Uint8Array(data.length + 1);
|
| 104 |
+
tempData.set(data);
|
| 105 |
+
data = tempData;
|
| 106 |
+
}
|
| 107 |
+
let offset = 8;
|
| 108 |
+
let finalSize = offset + data.length;
|
| 109 |
+
let writtenSize = data.length;
|
| 110 |
+
if (finalSize % 2 !== 0)
|
| 111 |
+
{
|
| 112 |
+
finalSize++;
|
| 113 |
+
}
|
| 114 |
+
let headerWritten = header;
|
| 115 |
+
if (isList)
|
| 116 |
+
{
|
| 117 |
+
finalSize += 4;
|
| 118 |
+
writtenSize += 4;
|
| 119 |
+
offset += 4;
|
| 120 |
+
headerWritten = "LIST";
|
| 121 |
+
}
|
| 122 |
+
const outArray = new IndexedByteArray(finalSize);
|
| 123 |
+
writeStringAsBytes(outArray, headerWritten);
|
| 124 |
+
writeDword(outArray, writtenSize);
|
| 125 |
+
if (isList)
|
| 126 |
+
{
|
| 127 |
+
writeStringAsBytes(outArray, header);
|
| 128 |
+
}
|
| 129 |
+
outArray.set(data, offset);
|
| 130 |
+
return outArray;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* @param collection {RiffChunk[]}
|
| 135 |
+
* @param type {string}
|
| 136 |
+
* @returns {RiffChunk|undefined}
|
| 137 |
+
*/
|
| 138 |
+
export function findRIFFListType(collection, type)
|
| 139 |
+
{
|
| 140 |
+
return collection.find(c =>
|
| 141 |
+
{
|
| 142 |
+
if (c.header !== "LIST")
|
| 143 |
+
{
|
| 144 |
+
return false;
|
| 145 |
+
}
|
| 146 |
+
c.chunkData.currentIndex = 0;
|
| 147 |
+
return readBytesAsString(c.chunkData, 4) === type;
|
| 148 |
+
});
|
| 149 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/write_dls/art2.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getDLSArticulatorFromSf2Generator, getDLSArticulatorFromSf2Modulator } from "./modulator_converter.js";
|
| 2 |
+
import { writeRIFFOddSize } from "../riff_chunk.js";
|
| 3 |
+
import { combineArrays, IndexedByteArray } from "../../../utils/indexed_array.js";
|
| 4 |
+
import { Generator, generatorTypes } from "../generator.js";
|
| 5 |
+
import { writeDword } from "../../../utils/byte_functions/little_endian.js";
|
| 6 |
+
import { consoleColors } from "../../../utils/other.js";
|
| 7 |
+
import { SpessaSynthInfo, SpessaSynthWarn } from "../../../utils/loggin.js";
|
| 8 |
+
import { Modulator } from "../modulator.js";
|
| 9 |
+
import {
|
| 10 |
+
DEFAULT_DLS_CHORUS,
|
| 11 |
+
DEFAULT_DLS_REVERB,
|
| 12 |
+
DLS_1_NO_VIBRATO_MOD,
|
| 13 |
+
DLS_1_NO_VIBRATO_PRESSURE
|
| 14 |
+
} from "../../dls/dls_sources.js";
|
| 15 |
+
|
| 16 |
+
const invalidGeneratorTypes = new Set([
|
| 17 |
+
generatorTypes.sampleModes,
|
| 18 |
+
generatorTypes.initialAttenuation,
|
| 19 |
+
generatorTypes.keyRange,
|
| 20 |
+
generatorTypes.velRange,
|
| 21 |
+
generatorTypes.sampleID,
|
| 22 |
+
generatorTypes.fineTune,
|
| 23 |
+
generatorTypes.coarseTune,
|
| 24 |
+
generatorTypes.startAddrsOffset,
|
| 25 |
+
generatorTypes.startAddrsCoarseOffset,
|
| 26 |
+
generatorTypes.endAddrOffset,
|
| 27 |
+
generatorTypes.endAddrsCoarseOffset,
|
| 28 |
+
generatorTypes.startloopAddrsOffset,
|
| 29 |
+
generatorTypes.startloopAddrsCoarseOffset,
|
| 30 |
+
generatorTypes.endloopAddrsOffset,
|
| 31 |
+
generatorTypes.endloopAddrsCoarseOffset,
|
| 32 |
+
generatorTypes.overridingRootKey,
|
| 33 |
+
generatorTypes.exclusiveClass
|
| 34 |
+
]);
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* @param zone {BasicInstrumentZone}
|
| 38 |
+
* @returns {IndexedByteArray}
|
| 39 |
+
*/
|
| 40 |
+
export function writeArticulator(zone)
|
| 41 |
+
{
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
// envelope generators are limited to 40 seconds
|
| 45 |
+
// in timecents, this is 1200 * log2(10) = 6386
|
| 46 |
+
|
| 47 |
+
for (let i = 0; i < zone.generators.length; i++)
|
| 48 |
+
{
|
| 49 |
+
const g = zone.generators[i];
|
| 50 |
+
if (
|
| 51 |
+
g.generatorType === generatorTypes.delayVolEnv ||
|
| 52 |
+
g.generatorType === generatorTypes.attackVolEnv ||
|
| 53 |
+
g.generatorType === generatorTypes.holdVolEnv ||
|
| 54 |
+
g.generatorType === generatorTypes.decayVolEnv ||
|
| 55 |
+
g.generatorType === generatorTypes.releaseVolEnv ||
|
| 56 |
+
g.generatorType === generatorTypes.delayModEnv ||
|
| 57 |
+
g.generatorType === generatorTypes.attackModEnv ||
|
| 58 |
+
g.generatorType === generatorTypes.holdModEnv ||
|
| 59 |
+
g.generatorType === generatorTypes.decayModEnv
|
| 60 |
+
)
|
| 61 |
+
{
|
| 62 |
+
zone.generators[i] = new Generator(g.generatorType, Math.min(g.generatorValue, 6386), false);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
// read_articulation.js:
|
| 68 |
+
// according to viena and another strange (with modulators) rendition of gm.dls in sf2,
|
| 69 |
+
// it shall be divided by -128,
|
| 70 |
+
// and a strange correction needs to be applied to the real value:
|
| 71 |
+
// real + (60 / 128) * scale
|
| 72 |
+
// we invert this here
|
| 73 |
+
for (let i = 0; i < zone.generators.length; i++)
|
| 74 |
+
{
|
| 75 |
+
const relativeGenerator = zone.generators[i];
|
| 76 |
+
let absoluteCounterpart = undefined;
|
| 77 |
+
switch (relativeGenerator.generatorType)
|
| 78 |
+
{
|
| 79 |
+
default:
|
| 80 |
+
continue;
|
| 81 |
+
|
| 82 |
+
case generatorTypes.keyNumToVolEnvDecay:
|
| 83 |
+
absoluteCounterpart = generatorTypes.decayVolEnv;
|
| 84 |
+
break;
|
| 85 |
+
case generatorTypes.keyNumToVolEnvHold:
|
| 86 |
+
absoluteCounterpart = generatorTypes.holdVolEnv;
|
| 87 |
+
break;
|
| 88 |
+
case generatorTypes.keyNumToModEnvDecay:
|
| 89 |
+
absoluteCounterpart = generatorTypes.decayModEnv;
|
| 90 |
+
break;
|
| 91 |
+
case generatorTypes.keyNumToModEnvHold:
|
| 92 |
+
absoluteCounterpart = generatorTypes.holdModEnv;
|
| 93 |
+
}
|
| 94 |
+
let absoluteGenerator = zone.generators.find(g => g.generatorType === absoluteCounterpart);
|
| 95 |
+
if (absoluteGenerator === undefined)
|
| 96 |
+
{
|
| 97 |
+
// there's no absolute generator here.
|
| 98 |
+
continue;
|
| 99 |
+
}
|
| 100 |
+
const dlsRelative = relativeGenerator.generatorValue * -128;
|
| 101 |
+
const subtraction = (60 / 128) * dlsRelative;
|
| 102 |
+
const newAbsolute = absoluteGenerator.generatorValue - subtraction;
|
| 103 |
+
|
| 104 |
+
const iR = zone.generators.indexOf(relativeGenerator);
|
| 105 |
+
const iA = zone.generators.indexOf(absoluteGenerator);
|
| 106 |
+
zone.generators[iA] =
|
| 107 |
+
new Generator(absoluteCounterpart, newAbsolute, false);
|
| 108 |
+
zone.generators[iR] =
|
| 109 |
+
new Generator(relativeGenerator.generatorType, dlsRelative, false);
|
| 110 |
+
}
|
| 111 |
+
/**
|
| 112 |
+
* @type {Articulator[]}
|
| 113 |
+
*/
|
| 114 |
+
const generators = zone.generators.reduce((arrs, g) =>
|
| 115 |
+
{
|
| 116 |
+
if (invalidGeneratorTypes.has(g.generatorType))
|
| 117 |
+
{
|
| 118 |
+
return arrs;
|
| 119 |
+
}
|
| 120 |
+
const art = getDLSArticulatorFromSf2Generator(g);
|
| 121 |
+
if (art !== undefined)
|
| 122 |
+
{
|
| 123 |
+
arrs.push(art);
|
| 124 |
+
SpessaSynthInfo("%cSucceeded converting to DLS Articulator!", consoleColors.recognized);
|
| 125 |
+
|
| 126 |
+
}
|
| 127 |
+
else
|
| 128 |
+
{
|
| 129 |
+
SpessaSynthWarn("Failed converting to DLS Articulator!");
|
| 130 |
+
}
|
| 131 |
+
return arrs;
|
| 132 |
+
}, []);
|
| 133 |
+
/**
|
| 134 |
+
* @type {Articulator[]}
|
| 135 |
+
*/
|
| 136 |
+
const modulators = zone.modulators.reduce((arrs, m) =>
|
| 137 |
+
{
|
| 138 |
+
// do not write the default DLS modulators
|
| 139 |
+
if (
|
| 140 |
+
Modulator.isIdentical(m, DEFAULT_DLS_CHORUS, true) ||
|
| 141 |
+
Modulator.isIdentical(m, DEFAULT_DLS_REVERB, true) ||
|
| 142 |
+
Modulator.isIdentical(m, DLS_1_NO_VIBRATO_MOD, true) ||
|
| 143 |
+
Modulator.isIdentical(m, DLS_1_NO_VIBRATO_PRESSURE, true)
|
| 144 |
+
)
|
| 145 |
+
{
|
| 146 |
+
return arrs;
|
| 147 |
+
}
|
| 148 |
+
const art = getDLSArticulatorFromSf2Modulator(m);
|
| 149 |
+
if (art !== undefined)
|
| 150 |
+
{
|
| 151 |
+
arrs.push(art);
|
| 152 |
+
SpessaSynthInfo("%cSucceeded converting to DLS Articulator!", consoleColors.recognized);
|
| 153 |
+
|
| 154 |
+
}
|
| 155 |
+
else
|
| 156 |
+
{
|
| 157 |
+
SpessaSynthWarn("Failed converting to DLS Articulator!");
|
| 158 |
+
}
|
| 159 |
+
return arrs;
|
| 160 |
+
}, []);
|
| 161 |
+
generators.push(...modulators);
|
| 162 |
+
|
| 163 |
+
const art2Data = new IndexedByteArray(8);
|
| 164 |
+
writeDword(art2Data, 8); // cbSize
|
| 165 |
+
writeDword(art2Data, generators.length); // cbConnectionBlocks
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
const out = generators.map(a => a.writeArticulator());
|
| 169 |
+
return writeRIFFOddSize(
|
| 170 |
+
"art2",
|
| 171 |
+
combineArrays([art2Data, ...out])
|
| 172 |
+
);
|
| 173 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/write_dls/articulator.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IndexedByteArray } from "../../../utils/indexed_array.js";
|
| 2 |
+
import { writeDword, writeWord } from "../../../utils/byte_functions/little_endian.js";
|
| 3 |
+
|
| 4 |
+
export class Articulator
|
| 5 |
+
{
|
| 6 |
+
/**
|
| 7 |
+
* @type {DLSSources}
|
| 8 |
+
*/
|
| 9 |
+
source;
|
| 10 |
+
/**
|
| 11 |
+
* @type {DLSSources}
|
| 12 |
+
*/
|
| 13 |
+
control;
|
| 14 |
+
/**
|
| 15 |
+
* @type {DLSDestinations}
|
| 16 |
+
*/
|
| 17 |
+
destination;
|
| 18 |
+
/**
|
| 19 |
+
* @type {number}
|
| 20 |
+
*/
|
| 21 |
+
scale;
|
| 22 |
+
/**
|
| 23 |
+
* @type {number}
|
| 24 |
+
*/
|
| 25 |
+
transform;
|
| 26 |
+
|
| 27 |
+
constructor(source, control, destination, scale, transform)
|
| 28 |
+
{
|
| 29 |
+
this.source = source;
|
| 30 |
+
this.control = control;
|
| 31 |
+
this.destination = destination;
|
| 32 |
+
this.scale = scale;
|
| 33 |
+
this.transform = transform;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* @returns {IndexedByteArray}
|
| 38 |
+
*/
|
| 39 |
+
writeArticulator()
|
| 40 |
+
{
|
| 41 |
+
const out = new IndexedByteArray(12);
|
| 42 |
+
writeWord(out, this.source);
|
| 43 |
+
writeWord(out, this.control);
|
| 44 |
+
writeWord(out, this.destination);
|
| 45 |
+
writeWord(out, this.transform);
|
| 46 |
+
writeDword(out, this.scale << 16);
|
| 47 |
+
return out;
|
| 48 |
+
}
|
| 49 |
+
}
|
spessasynth_lib/soundfont/basic_soundfont/write_dls/combine_zones.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Modulator } from "../modulator.js";
|
| 2 |
+
import { BasicInstrumentZone } from "../basic_zones.js";
|
| 3 |
+
import { Generator, generatorLimits, generatorTypes } from "../generator.js";
|
| 4 |
+
|
| 5 |
+
const notGlobalizedTypes = new Set([
|
| 6 |
+
generatorTypes.velRange,
|
| 7 |
+
generatorTypes.keyRange,
|
| 8 |
+
generatorTypes.instrument,
|
| 9 |
+
generatorTypes.exclusiveClass,
|
| 10 |
+
generatorTypes.endOper,
|
| 11 |
+
generatorTypes.sampleModes,
|
| 12 |
+
generatorTypes.startloopAddrsOffset,
|
| 13 |
+
generatorTypes.startloopAddrsCoarseOffset,
|
| 14 |
+
generatorTypes.endloopAddrsOffset,
|
| 15 |
+
generatorTypes.endloopAddrsCoarseOffset,
|
| 16 |
+
generatorTypes.startAddrsOffset,
|
| 17 |
+
generatorTypes.startAddrsCoarseOffset,
|
| 18 |
+
generatorTypes.endAddrOffset,
|
| 19 |
+
generatorTypes.endAddrsCoarseOffset,
|
| 20 |
+
generatorTypes.initialAttenuation, // written into wsmp, there's no global wsmp
|
| 21 |
+
generatorTypes.fineTune, // written into wsmp, there's no global wsmp
|
| 22 |
+
generatorTypes.coarseTune, // written into wsmp, there's no global wsmp
|
| 23 |
+
generatorTypes.keyNumToVolEnvHold, // KEY TO SOMETHING:
|
| 24 |
+
generatorTypes.keyNumToVolEnvDecay,// cannot be globalized as they modify their respective generators
|
| 25 |
+
generatorTypes.keyNumToModEnvHold, // (for example, keyNumToVolEnvDecay modifies VolEnvDecay)
|
| 26 |
+
generatorTypes.keyNumToModEnvDecay
|
| 27 |
+
]);
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Combines preset zones
|
| 31 |
+
* @param preset {BasicPreset}
|
| 32 |
+
* @param globalize {boolean}
|
| 33 |
+
* @returns {BasicInstrumentZone[]}
|
| 34 |
+
*/
|
| 35 |
+
export function combineZones(preset, globalize = true)
|
| 36 |
+
{
|
| 37 |
+
/**
|
| 38 |
+
* @param main {Generator[]}
|
| 39 |
+
* @param adder {Generator[]}
|
| 40 |
+
*/
|
| 41 |
+
function addUnique(main, adder)
|
| 42 |
+
{
|
| 43 |
+
main.push(...adder.filter(g => !main.find(mg => mg.generatorType === g.generatorType)));
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* @param r1 {SoundFontRange}
|
| 48 |
+
* @param r2 {SoundFontRange}
|
| 49 |
+
* @returns {SoundFontRange}
|
| 50 |
+
*/
|
| 51 |
+
function subtractRanges(r1, r2)
|
| 52 |
+
{
|
| 53 |
+
return { min: Math.max(r1.min, r2.min), max: Math.min(r1.max, r2.max) };
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* @param main {Modulator[]}
|
| 58 |
+
* @param adder {Modulator[]}
|
| 59 |
+
*/
|
| 60 |
+
function addUniqueMods(main, adder)
|
| 61 |
+
{
|
| 62 |
+
main.push(...adder.filter(m => !main.find(mm => Modulator.isIdentical(m, mm))));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* @type {BasicInstrumentZone[]}
|
| 67 |
+
*/
|
| 68 |
+
const finalZones = [];
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* @type {Generator[]}
|
| 72 |
+
*/
|
| 73 |
+
const globalPresetGenerators = [];
|
| 74 |
+
/**
|
| 75 |
+
* @type {Modulator[]}
|
| 76 |
+
*/
|
| 77 |
+
const globalPresetModulators = [];
|
| 78 |
+
let globalPresetKeyRange = { min: 0, max: 127 };
|
| 79 |
+
let globalPresetVelRange = { min: 0, max: 127 };
|
| 80 |
+
|
| 81 |
+
// find the global zone and apply ranges, generators, and modulators
|
| 82 |
+
const globalPresetZone = preset.presetZones.find(z => z.isGlobal);
|
| 83 |
+
if (globalPresetZone)
|
| 84 |
+
{
|
| 85 |
+
globalPresetGenerators.push(...globalPresetZone.generators);
|
| 86 |
+
globalPresetModulators.push(...globalPresetZone.modulators);
|
| 87 |
+
globalPresetKeyRange = globalPresetZone.keyRange;
|
| 88 |
+
globalPresetVelRange = globalPresetZone.velRange;
|
| 89 |
+
}
|
| 90 |
+
// for each non-global preset zone
|
| 91 |
+
for (const presetZone of preset.presetZones)
|
| 92 |
+
{
|
| 93 |
+
if (presetZone.isGlobal)
|
| 94 |
+
{
|
| 95 |
+
continue;
|
| 96 |
+
}
|
| 97 |
+
// use global ranges if not provided
|
| 98 |
+
let presetZoneKeyRange = presetZone.keyRange;
|
| 99 |
+
if (!presetZone.hasKeyRange)
|
| 100 |
+
{
|
| 101 |
+
presetZoneKeyRange = globalPresetKeyRange;
|
| 102 |
+
}
|
| 103 |
+
let presetZoneVelRange = presetZone.velRange;
|
| 104 |
+
if (!presetZone.hasVelRange)
|
| 105 |
+
{
|
| 106 |
+
presetZoneVelRange = globalPresetVelRange;
|
| 107 |
+
}
|
| 108 |
+
// add unique generators and modulators from the global zone
|
| 109 |
+
const presetGenerators = presetZone.generators.map(g => new Generator(g.generatorType, g.generatorValue));
|
| 110 |
+
addUnique(presetGenerators, globalPresetGenerators);
|
| 111 |
+
const presetModulators = [...presetZone.modulators];
|
| 112 |
+
addUniqueMods(presetModulators, globalPresetModulators);
|
| 113 |
+
|
| 114 |
+
const iZones = presetZone.instrument.instrumentZones;
|
| 115 |
+
/**
|
| 116 |
+
* @type {Generator[]}
|
| 117 |
+
*/
|
| 118 |
+
const globalInstGenerators = [];
|
| 119 |
+
/**
|
| 120 |
+
* @type {Modulator[]}
|
| 121 |
+
*/
|
| 122 |
+
const globalInstModulators = [];
|
| 123 |
+
let globalInstKeyRange = { min: 0, max: 127 };
|
| 124 |
+
let globalInstVelRange = { min: 0, max: 127 };
|
| 125 |
+
const globalInstZone = iZones.find(z => z.isGlobal);
|
| 126 |
+
if (globalInstZone)
|
| 127 |
+
{
|
| 128 |
+
globalInstGenerators.push(...globalInstZone.generators);
|
| 129 |
+
globalInstModulators.push(...globalInstZone.modulators);
|
| 130 |
+
globalInstKeyRange = globalInstZone.keyRange;
|
| 131 |
+
globalInstVelRange = globalInstZone.velRange;
|
| 132 |
+
}
|
| 133 |
+
// for each non-global instrument zone
|
| 134 |
+
for (const instZone of iZones)
|
| 135 |
+
{
|
| 136 |
+
if (instZone.isGlobal)
|
| 137 |
+
{
|
| 138 |
+
continue;
|
| 139 |
+
}
|
| 140 |
+
// use global ranges if not provided
|
| 141 |
+
let instZoneKeyRange = instZone.keyRange;
|
| 142 |
+
if (!instZone.hasKeyRange)
|
| 143 |
+
{
|
| 144 |
+
instZoneKeyRange = globalInstKeyRange;
|
| 145 |
+
}
|
| 146 |
+
let instZoneVelRange = instZone.velRange;
|
| 147 |
+
if (!instZone.hasVelRange)
|
| 148 |
+
{
|
| 149 |
+
instZoneVelRange = globalInstVelRange;
|
| 150 |
+
}
|
| 151 |
+
instZoneKeyRange = subtractRanges(instZoneKeyRange, presetZoneKeyRange);
|
| 152 |
+
instZoneVelRange = subtractRanges(instZoneVelRange, presetZoneVelRange);
|
| 153 |
+
|
| 154 |
+
// if either of the zones is out of range (i.e.m min larger than the max),
|
| 155 |
+
// then we discard that zone
|
| 156 |
+
if (instZoneKeyRange.max < instZoneKeyRange.min || instZoneVelRange.max < instZoneVelRange.min)
|
| 157 |
+
{
|
| 158 |
+
continue;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// add unique generators and modulators from the global zone
|
| 162 |
+
const instGenerators = instZone.generators.map(g => new Generator(g.generatorType, g.generatorValue));
|
| 163 |
+
addUnique(instGenerators, globalInstGenerators);
|
| 164 |
+
const instModulators = [...instZone.modulators];
|
| 165 |
+
addUniqueMods(instModulators, globalInstModulators);
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* sum preset modulators to instruments (amount) sf spec page 54
|
| 169 |
+
* @type {Modulator[]}
|
| 170 |
+
*/
|
| 171 |
+
const finalModList = [...instModulators];
|
| 172 |
+
for (const mod of presetModulators)
|
| 173 |
+
{
|
| 174 |
+
const identicalInstMod = finalModList.findIndex(
|
| 175 |
+
m => Modulator.isIdentical(mod, m));
|
| 176 |
+
if (identicalInstMod !== -1)
|
| 177 |
+
{
|
| 178 |
+
// sum the amounts
|
| 179 |
+
// (this makes a new modulator
|
| 180 |
+
// because otherwise it would overwrite the one in the soundfont!
|
| 181 |
+
finalModList[identicalInstMod] = finalModList[identicalInstMod].sumTransform(
|
| 182 |
+
mod);
|
| 183 |
+
}
|
| 184 |
+
else
|
| 185 |
+
{
|
| 186 |
+
finalModList.push(mod);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// clone the generators as the values are modified during DLS conversion (keyNumToSomething)
|
| 191 |
+
let finalGenList = instGenerators.map(g => new Generator(g.generatorType, g.generatorValue));
|
| 192 |
+
for (const gen of presetGenerators)
|
| 193 |
+
{
|
| 194 |
+
if (gen.generatorType === generatorTypes.velRange ||
|
| 195 |
+
gen.generatorType === generatorTypes.keyRange ||
|
| 196 |
+
gen.generatorType === generatorTypes.instrument ||
|
| 197 |
+
gen.generatorType === generatorTypes.endOper ||
|
| 198 |
+
gen.generatorType === generatorTypes.sampleModes)
|
| 199 |
+
{
|
| 200 |
+
continue;
|
| 201 |
+
}
|
| 202 |
+
const identicalInstGen = instGenerators.findIndex(g => g.generatorType === gen.generatorType);
|
| 203 |
+
if (identicalInstGen !== -1)
|
| 204 |
+
{
|
| 205 |
+
// if exists, sum to that generator
|
| 206 |
+
const newAmount = finalGenList[identicalInstGen].generatorValue + gen.generatorValue;
|
| 207 |
+
finalGenList[identicalInstGen] = new Generator(gen.generatorType, newAmount);
|
| 208 |
+
}
|
| 209 |
+
else
|
| 210 |
+
{
|
| 211 |
+
// if not, sum to the default generator
|
| 212 |
+
const newAmount = generatorLimits[gen.generatorType].def + gen.generatorValue;
|
| 213 |
+
finalGenList.push(new Generator(gen.generatorType, newAmount));
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// remove unwanted
|
| 218 |
+
finalGenList = finalGenList.filter(g =>
|
| 219 |
+
g.generatorType !== generatorTypes.sampleID &&
|
| 220 |
+
g.generatorType !== generatorTypes.keyRange &&
|
| 221 |
+
g.generatorType !== generatorTypes.velRange &&
|
| 222 |
+
g.generatorType !== generatorTypes.endOper &&
|
| 223 |
+
g.generatorType !== generatorTypes.instrument &&
|
| 224 |
+
g.generatorValue !== generatorLimits[g.generatorType].def
|
| 225 |
+
);
|
| 226 |
+
|
| 227 |
+
// create the zone and copy over values
|
| 228 |
+
const zone = new BasicInstrumentZone();
|
| 229 |
+
zone.keyRange = instZoneKeyRange;
|
| 230 |
+
zone.velRange = instZoneVelRange;
|
| 231 |
+
if (zone.keyRange.min === 0 && zone.keyRange.max === 127)
|
| 232 |
+
{
|
| 233 |
+
zone.keyRange.min = -1;
|
| 234 |
+
}
|
| 235 |
+
if (zone.velRange.min === 0 && zone.velRange.max === 127)
|
| 236 |
+
{
|
| 237 |
+
zone.velRange.min = -1;
|
| 238 |
+
}
|
| 239 |
+
zone.isGlobal = false;
|
| 240 |
+
zone.sample = instZone.sample;
|
| 241 |
+
zone.generators = finalGenList;
|
| 242 |
+
zone.modulators = finalModList;
|
| 243 |
+
finalZones.push(zone);
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
if (globalize)
|
| 248 |
+
{
|
| 249 |
+
// create a global zone and add repeating generators to it
|
| 250 |
+
// also modulators
|
| 251 |
+
const globalZone = new BasicInstrumentZone();
|
| 252 |
+
globalZone.isGlobal = true;
|
| 253 |
+
// iterate over every type of generator
|
| 254 |
+
for (let checkedType = 0; checkedType < 58; checkedType++)
|
| 255 |
+
{
|
| 256 |
+
// not these though
|
| 257 |
+
if (notGlobalizedTypes.has(checkedType))
|
| 258 |
+
{
|
| 259 |
+
continue;
|
| 260 |
+
}
|
| 261 |
+
/**
|
| 262 |
+
* @type {Object<string, number>}
|
| 263 |
+
*/
|
| 264 |
+
let occurencesForValues = {};
|
| 265 |
+
const defaultForChecked = generatorLimits[checkedType]?.def || 0;
|
| 266 |
+
occurencesForValues[defaultForChecked] = 0;
|
| 267 |
+
for (const z of finalZones)
|
| 268 |
+
{
|
| 269 |
+
const gen = z.generators.find(g => g.generatorType === checkedType);
|
| 270 |
+
if (gen)
|
| 271 |
+
{
|
| 272 |
+
const value = gen.generatorValue;
|
| 273 |
+
if (occurencesForValues[value] === undefined)
|
| 274 |
+
{
|
| 275 |
+
occurencesForValues[value] = 1;
|
| 276 |
+
}
|
| 277 |
+
else
|
| 278 |
+
{
|
| 279 |
+
occurencesForValues[value]++;
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
else
|
| 283 |
+
{
|
| 284 |
+
occurencesForValues[defaultForChecked]++;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// if the checked type has the keyNumTo something generator set, it cannot be globalized.
|
| 288 |
+
let relativeCounterpart;
|
| 289 |
+
switch (checkedType)
|
| 290 |
+
{
|
| 291 |
+
default:
|
| 292 |
+
continue;
|
| 293 |
+
|
| 294 |
+
case generatorTypes.decayVolEnv:
|
| 295 |
+
relativeCounterpart = generatorTypes.keyNumToVolEnvDecay;
|
| 296 |
+
break;
|
| 297 |
+
case generatorTypes.holdVolEnv:
|
| 298 |
+
relativeCounterpart = generatorTypes.keyNumToVolEnvHold;
|
| 299 |
+
break;
|
| 300 |
+
case generatorTypes.decayModEnv:
|
| 301 |
+
relativeCounterpart = generatorTypes.keyNumToModEnvDecay;
|
| 302 |
+
break;
|
| 303 |
+
case generatorTypes.holdModEnv:
|
| 304 |
+
relativeCounterpart = generatorTypes.keyNumToModEnvHold;
|
| 305 |
+
}
|
| 306 |
+
const relative = z.generators.find(g => g.generatorType === relativeCounterpart);
|
| 307 |
+
if (relative !== undefined)
|
| 308 |
+
{
|
| 309 |
+
occurencesForValues = {};
|
| 310 |
+
break;
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
// if at least one occurrence, find the most used one and add it to global
|
| 314 |
+
if (Object.keys(occurencesForValues).length > 0)
|
| 315 |
+
{
|
| 316 |
+
// [value, occurrences]
|
| 317 |
+
const valueToGlobalize = Object.entries(occurencesForValues).reduce((max, curr) =>
|
| 318 |
+
{
|
| 319 |
+
if (max[1] < curr[1])
|
| 320 |
+
{
|
| 321 |
+
return curr;
|
| 322 |
+
}
|
| 323 |
+
return max;
|
| 324 |
+
}, [0, 0]);
|
| 325 |
+
const targetValue = parseInt(valueToGlobalize[0]);
|
| 326 |
+
|
| 327 |
+
// if the global value is the default value just remove it, no need to add it
|
| 328 |
+
if (targetValue !== defaultForChecked)
|
| 329 |
+
{
|
| 330 |
+
globalZone.generators.push(new Generator(checkedType, targetValue));
|
| 331 |
+
}
|
| 332 |
+
// remove from the zones
|
| 333 |
+
finalZones.forEach(z =>
|
| 334 |
+
{
|
| 335 |
+
const gen = z.generators.findIndex(g =>
|
| 336 |
+
g.generatorType === checkedType);
|
| 337 |
+
if (gen !== -1)
|
| 338 |
+
{
|
| 339 |
+
if (z.generators[gen].generatorValue === targetValue)
|
| 340 |
+
{
|
| 341 |
+
// That exact value exists. Since it's global now, remove it
|
| 342 |
+
z.generators.splice(gen, 1);
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
else
|
| 346 |
+
{
|
| 347 |
+
// That type does not exist at all here.
|
| 348 |
+
// Since we're globalizing, we need to add the default here.
|
| 349 |
+
if (targetValue !== defaultForChecked)
|
| 350 |
+
{
|
| 351 |
+
z.generators.push(new Generator(checkedType, defaultForChecked));
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
});
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// globalize only modulators that exist in all zones
|
| 359 |
+
const firstZone = finalZones.find(z => !z.isGlobal);
|
| 360 |
+
const modulators = firstZone.modulators.map(m => Modulator.copy(m));
|
| 361 |
+
for (const checkedModulator of modulators)
|
| 362 |
+
{
|
| 363 |
+
let existsForAllZones = true;
|
| 364 |
+
for (const zone of finalZones)
|
| 365 |
+
{
|
| 366 |
+
if (zone.isGlobal || !existsForAllZones)
|
| 367 |
+
{
|
| 368 |
+
continue;
|
| 369 |
+
}
|
| 370 |
+
// check if that zone has an existing modulator
|
| 371 |
+
const mod = zone.modulators.find(m => Modulator.isIdentical(m, checkedModulator));
|
| 372 |
+
if (!mod)
|
| 373 |
+
{
|
| 374 |
+
// does not exist for this zone, so it's not global.
|
| 375 |
+
existsForAllZones = false;
|
| 376 |
+
}
|
| 377 |
+
// exists.
|
| 378 |
+
|
| 379 |
+
}
|
| 380 |
+
if (existsForAllZones === true)
|
| 381 |
+
{
|
| 382 |
+
globalZone.modulators.push(Modulator.copy(checkedModulator));
|
| 383 |
+
// delete it from local zones.
|
| 384 |
+
for (const zone of finalZones)
|
| 385 |
+
{
|
| 386 |
+
const modulator = zone.modulators.find(m => Modulator.isIdentical(m, checkedModulator));
|
| 387 |
+
// Check if the amount is correct.
|
| 388 |
+
// If so, delete it since it's global.
|
| 389 |
+
// If not, then it will simply override global as it's identical.
|
| 390 |
+
if (modulator.transformAmount === checkedModulator.transformAmount)
|
| 391 |
+
{
|
| 392 |
+
zone.modulators.splice(zone.modulators.indexOf(modulator), 1);
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
finalZones.splice(0, 0, globalZone);
|
| 398 |
+
}
|
| 399 |
+
return finalZones;
|
| 400 |
+
}
|