forked from Cycling74/rnbo.example.webpage
-
Notifications
You must be signed in to change notification settings - Fork 0
/
app.js
339 lines (282 loc) · 12.6 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
async function setup() {
const patchExportURL = "export/patch.export.json";
// Create AudioContext
const WAContext = window.AudioContext || window.webkitAudioContext;
const context = new WAContext();
// Create gain node and connect it to audio output
const outputNode = context.createGain();
outputNode.connect(context.destination);
// Fetch the exported patcher
let response, patcher;
try {
response = await fetch(patchExportURL);
patcher = await response.json();
if (!window.RNBO) {
// Load RNBO script dynamically
// Note that you can skip this by knowing the RNBO version of your patch
// beforehand and just include it using a <script> tag
await loadRNBOScript(patcher.desc.meta.rnboversion);
}
} catch (err) {
const errorContext = {
error: err
};
if (response && (response.status >= 300 || response.status < 200)) {
errorContext.header = `Couldn't load patcher export bundle`,
errorContext.description = `Check app.js to see what file it's trying to load. Currently it's` +
` trying to load "${patchExportURL}". If that doesn't` +
` match the name of the file you exported from RNBO, modify` +
` patchExportURL in app.js.`;
}
if (typeof guardrails === "function") {
guardrails(errorContext);
} else {
throw err;
}
return;
}
// (Optional) Fetch the dependencies
let dependencies = [];
try {
const dependenciesResponse = await fetch("export/dependencies.json");
dependencies = await dependenciesResponse.json();
// Prepend "export" to any file dependenciies
dependencies = dependencies.map(d => d.file ? Object.assign({}, d, { file: "export/" + d.file }) : d);
} catch (e) {}
// Create the device
let device;
try {
device = await RNBO.createDevice({ context, patcher });
} catch (err) {
if (typeof guardrails === "function") {
guardrails({ error: err });
} else {
throw err;
}
return;
}
// (Optional) Load the samples
if (dependencies.length)
await device.loadDataBufferDependencies(dependencies);
// Connect the device to the web audio graph
device.node.connect(outputNode);
// (Optional) Extract the name and rnbo version of the patcher from the description
document.getElementById("patcher-title").innerText = (patcher.desc.meta.filename || "Unnamed Patcher") + " (v" + patcher.desc.meta.rnboversion + ")";
// (Optional) Automatically create sliders for the device parameters
makeSliders(device);
// (Optional) Create a form to send messages to RNBO inputs
makeInportForm(device);
// (Optional) Attach listeners to outports so you can log messages from the RNBO patcher
attachOutports(device);
// (Optional) Load presets, if any
loadPresets(device, patcher);
// (Optional) Connect MIDI inputs
makeMIDIKeyboard(device);
document.body.onclick = () => {
context.resume();
}
// Skip if you're not using guardrails.js
if (typeof guardrails === "function")
guardrails();
}
function loadRNBOScript(version) {
return new Promise((resolve, reject) => {
if (/^\d+\.\d+\.\d+-dev$/.test(version)) {
throw new Error("Patcher exported with a Debug Version!\nPlease specify the correct RNBO version to use in the code.");
}
const el = document.createElement("script");
el.src = "https://c74-public.nyc3.digitaloceanspaces.com/rnbo/" + encodeURIComponent(version) + "/rnbo.min.js";
el.onload = resolve;
el.onerror = function(err) {
console.log(err);
reject(new Error("Failed to load rnbo.js v" + version));
};
document.body.append(el);
});
}
function makeSliders(device) {
let pdiv = document.getElementById("rnbo-parameter-sliders");
let noParamLabel = document.getElementById("no-param-label");
if (noParamLabel && device.numParameters > 0) pdiv.removeChild(noParamLabel);
// This will allow us to ignore parameter update events while dragging the slider.
let isDraggingSlider = false;
let uiElements = {};
device.parameters.forEach(param => {
// Subpatchers also have params. If we want to expose top-level
// params only, the best way to determine if a parameter is top level
// or not is to exclude parameters with a '/' in them.
// You can uncomment the following line if you don't want to include subpatcher params
//if (param.id.includes("/")) return;
// Create a label, an input slider and a value display
let label = document.createElement("label");
let slider = document.createElement("input");
let text = document.createElement("input");
let sliderContainer = document.createElement("div");
sliderContainer.appendChild(label);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(text);
// Add a name for the label
label.setAttribute("name", param.name);
label.setAttribute("for", param.name);
label.setAttribute("class", "param-label");
label.textContent = `${param.name}: `;
// Make each slider reflect its parameter
slider.setAttribute("type", "range");
slider.setAttribute("class", "param-slider");
slider.setAttribute("id", param.id);
slider.setAttribute("name", param.name);
slider.setAttribute("min", param.min);
slider.setAttribute("max", param.max);
if (param.steps > 1) {
slider.setAttribute("step", (param.max - param.min) / (param.steps - 1));
} else {
slider.setAttribute("step", (param.max - param.min) / 1000.0);
}
slider.setAttribute("value", param.value);
// Make a settable text input display for the value
text.setAttribute("value", param.value.toFixed(1));
text.setAttribute("type", "text");
// Make each slider control its parameter
slider.addEventListener("pointerdown", () => {
isDraggingSlider = true;
});
slider.addEventListener("pointerup", () => {
isDraggingSlider = false;
slider.value = param.value;
text.value = param.value.toFixed(1);
});
slider.addEventListener("input", () => {
let value = Number.parseFloat(slider.value);
param.value = value;
});
// Make the text box input control the parameter value as well
text.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
let newValue = Number.parseFloat(text.value);
if (isNaN(newValue)) {
text.value = param.value;
} else {
newValue = Math.min(newValue, param.max);
newValue = Math.max(newValue, param.min);
text.value = newValue;
param.value = newValue;
}
}
});
// Store the slider and text by name so we can access them later
uiElements[param.id] = { slider, text };
// Add the slider element
pdiv.appendChild(sliderContainer);
});
// Listen to parameter changes from the device
device.parameterChangeEvent.subscribe(param => {
if (!isDraggingSlider)
uiElements[param.id].slider.value = param.value;
uiElements[param.id].text.value = param.value.toFixed(1);
});
}
function makeInportForm(device) {
const idiv = document.getElementById("rnbo-inports");
const inportSelect = document.getElementById("inport-select");
const inportText = document.getElementById("inport-text");
const inportForm = document.getElementById("inport-form");
let inportTag = null;
// Device messages correspond to inlets/outlets or inports/outports
// You can filter for one or the other using the "type" of the message
const messages = device.messages;
const inports = messages.filter(message => message.type === RNBO.MessagePortType.Inport);
if (inports.length === 0) {
idiv.removeChild(document.getElementById("inport-form"));
return;
} else {
idiv.removeChild(document.getElementById("no-inports-label"));
inports.forEach(inport => {
const option = document.createElement("option");
option.innerText = inport.tag;
inportSelect.appendChild(option);
});
inportSelect.onchange = () => inportTag = inportSelect.value;
inportTag = inportSelect.value;
inportForm.onsubmit = (ev) => {
// Do this or else the page will reload
ev.preventDefault();
// Turn the text into a list of numbers (RNBO messages must be numbers, not text)
const values = inportText.value.split(/\s+/).map(s => parseFloat(s));
// Send the message event to the RNBO device
let messageEvent = new RNBO.MessageEvent(RNBO.TimeNow, inportTag, values);
device.scheduleEvent(messageEvent);
}
}
}
function attachOutports(device) {
const outports = device.outports;
if (outports.length < 1) {
document.getElementById("rnbo-console").removeChild(document.getElementById("rnbo-console-div"));
return;
}
document.getElementById("rnbo-console").removeChild(document.getElementById("no-outports-label"));
device.messageEvent.subscribe((ev) => {
// Ignore message events that don't belong to an outport
if (outports.findIndex(elt => elt.tag === ev.tag) < 0) return;
// Message events have a tag as well as a payload
console.log(`${ev.tag}: ${ev.payload}`);
document.getElementById("rnbo-console-readout").innerText = `${ev.tag}: ${ev.payload}`;
});
}
function loadPresets(device, patcher) {
let presets = patcher.presets || [];
if (presets.length < 1) {
document.getElementById("rnbo-presets").removeChild(document.getElementById("preset-select"));
return;
}
document.getElementById("rnbo-presets").removeChild(document.getElementById("no-presets-label"));
let presetSelect = document.getElementById("preset-select");
presets.forEach((preset, index) => {
const option = document.createElement("option");
option.innerText = preset.name;
option.value = index;
presetSelect.appendChild(option);
});
presetSelect.onchange = () => device.setPreset(presets[presetSelect.value].preset);
}
function makeMIDIKeyboard(device) {
let mdiv = document.getElementById("rnbo-clickable-keyboard");
if (device.numMIDIInputPorts === 0) return;
mdiv.removeChild(document.getElementById("no-midi-label"));
const midiNotes = [49, 52, 56, 63];
midiNotes.forEach(note => {
const key = document.createElement("div");
const label = document.createElement("p");
label.textContent = note;
key.appendChild(label);
key.addEventListener("pointerdown", () => {
let midiChannel = 0;
// Format a MIDI message paylaod, this constructs a MIDI on event
let noteOnMessage = [
144 + midiChannel, // Code for a note on: 10010000 & midi channel (0-15)
note, // MIDI Note
100 // MIDI Velocity
];
let noteOffMessage = [
128 + midiChannel, // Code for a note off: 10000000 & midi channel (0-15)
note, // MIDI Note
0 // MIDI Velocity
];
// Including rnbo.min.js (or the unminified rnbo.js) will add the RNBO object
// to the global namespace. This includes the TimeNow constant as well as
// the MIDIEvent constructor.
let midiPort = 0;
let noteDurationMs = 250;
// When scheduling an event to occur in the future, use the current audio context time
// multiplied by 1000 (converting seconds to milliseconds) for now.
let noteOnEvent = new RNBO.MIDIEvent(device.context.currentTime * 1000, midiPort, noteOnMessage);
let noteOffEvent = new RNBO.MIDIEvent(device.context.currentTime * 1000 + noteDurationMs, midiPort, noteOffMessage);
device.scheduleEvent(noteOnEvent);
device.scheduleEvent(noteOffEvent);
key.classList.add("clicked");
});
key.addEventListener("pointerup", () => key.classList.remove("clicked"));
mdiv.appendChild(key);
});
}
setup();