diff --git a/assets/example_patches/808/cmaj_808.js b/assets/example_patches/808/cmaj_808.js index df0d2417..8ed81dc5 100644 --- a/assets/example_patches/808/cmaj_808.js +++ b/assets/example_patches/808/cmaj_808.js @@ -312,26 +312,55 @@ class TR808 /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 1631264; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 4; + source += 4; + } } } diff --git a/assets/example_patches/808/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/808/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/808/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/808/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js b/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js index 524397c4..6dc478e0 100644 --- a/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js +++ b/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js @@ -415,26 +415,55 @@ class ElectricPiano /** Copies frames from the output stream "audioOut" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_audioOut (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_audioOut (destChannelArrays, maxNumFramesToRead) { let source = 134672; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (2, destChannelArrays.length - destChannel); + if (numDestChans < 2) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 8; + } + } + else if (numDestChans > 2) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 0; channel < 2; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 2; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 8; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 2; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 8; + source += 8; + } } } diff --git a/assets/example_patches/ElectricPiano/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/ElectricPiano/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/ElectricPiano/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/ElectricPiano/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/HelloWorld/cmaj_Hello_World.js b/assets/example_patches/HelloWorld/cmaj_Hello_World.js index ba800bfa..020e21c5 100644 --- a/assets/example_patches/HelloWorld/cmaj_Hello_World.js +++ b/assets/example_patches/HelloWorld/cmaj_Hello_World.js @@ -197,26 +197,55 @@ class HelloWorld /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 71744; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 4; + source += 4; + } } } diff --git a/assets/example_patches/HelloWorld/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/HelloWorld/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/HelloWorld/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/HelloWorld/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/Piano/cmaj_Piano.js b/assets/example_patches/Piano/cmaj_Piano.js index 29b581fe..e8c0ecf0 100644 --- a/assets/example_patches/Piano/cmaj_Piano.js +++ b/assets/example_patches/Piano/cmaj_Piano.js @@ -268,26 +268,55 @@ class Piano /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 1457664; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 4; + source += 4; + } } } diff --git a/assets/example_patches/Piano/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/Piano/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/Piano/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/Piano/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/PirkleFilters/README.md b/assets/example_patches/PirkleFilters/README.md new file mode 100644 index 00000000..dca15f43 --- /dev/null +++ b/assets/example_patches/PirkleFilters/README.md @@ -0,0 +1,23 @@ +### Auto-generated HTML & Javascript for Cmajor Patch "vafilters" + +This folder contains some self-contained HTML/Javascript files that play and show a Cmajor +patch using WebAssembly and WebAudio. + +For `index.html` to display correctly, this folder needs to be served as HTTP, so if you're +running it locally, you'll need to start a webserver that serves this folder, and then +point your browser at whatever URL your webserver provides. For example, you could run +`python3 -m http.server` in this folder, and then browse to the address it chooses. + +The files have all been generated using the Cmajor command-line tool: +``` +cmaj generate --target=webaudio --output= +``` + +- `index.html` is a minimal page that creates the javascript object that implements the patch, + connects it to the default audio and MIDI devices, and displays its view. +- `cmaj_vafilters.js` - this is the Javascript wrapper class for the patch, encapsulating its + DSP as webassembly, and providing an API that is used to both render the audio and + control its properties. +- `cmaj_api` - this folder contains javascript helper modules and resources. + +To learn more about Cmajor, visit [cmajor.dev](cmajor.dev) diff --git a/assets/example_patches/PirkleFilters/cmaj_api/assets/cmajor-logo.svg b/assets/example_patches/PirkleFilters/cmaj_api/assets/cmajor-logo.svg new file mode 100644 index 00000000..70685d54 --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/assets/cmajor-logo.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-event-listener-list.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-event-listener-list.js new file mode 100644 index 00000000..0c13ea9e --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-event-listener-list.js @@ -0,0 +1,112 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +/** This event listener management class allows listeners to be attached and + * removed from named event types. + */ +export class EventListenerList +{ + constructor() + { + this.listenersPerType = {}; + } + + /** Adds a listener for a specifc event type. + * If the listener is already registered, this will simply add it again. + * Each call to addEventListener() must be paired with a removeventListener() + * call to remove it. + * + * @param {string} type + */ + addEventListener (type, listener) + { + if (type && listener) + { + const list = this.listenersPerType[type]; + + if (list) + list.push (listener); + else + this.listenersPerType[type] = [listener]; + } + } + + /** Removes a listener that was previously added for the given event type. + * @param {string} type + */ + removeEventListener (type, listener) + { + if (type && listener) + { + const list = this.listenersPerType[type]; + + if (list) + { + const i = list.indexOf (listener); + + if (i >= 0) + list.splice (i, 1); + } + } + } + + /** Attaches a callback function that will be automatically unregistered + * the first time it is invoked. + * + * @param {string} type + */ + addSingleUseListener (type, listener) + { + const l = message => + { + this.removeEventListener (type, l); + listener?.(message); + }; + + this.addEventListener (type, l); + } + + /** Synchronously dispatches an event object to all listeners + * that are registered for the given type. + * + * @param {string} type + */ + dispatchEvent (type, event) + { + const list = this.listenersPerType[type]; + + if (list) + for (const listener of list) + listener?.(event); + } + + /** Returns the number of listeners that are currently registered + * for the given type of event. + * + * @param {string} type + */ + getNumListenersForType (type) + { + const list = this.listenersPerType[type]; + return list ? list.length : 0; + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-generic-patch-view.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-generic-patch-view.js new file mode 100644 index 00000000..0370dc96 --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-generic-patch-view.js @@ -0,0 +1,186 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import * as Controls from "./cmaj-parameter-controls.js" + +//============================================================================== +/** A simple, generic view which can control any type of patch */ +class GenericPatchView extends HTMLElement +{ + /** Creates a view for a patch. + * @param {PatchConnection} patchConnection - the connection to the target patch + */ + constructor (patchConnection) + { + super(); + + this.patchConnection = patchConnection; + + this.statusListener = status => + { + this.status = status; + this.createControlElements(); + }; + + this.attachShadow ({ mode: "open" }); + this.shadowRoot.innerHTML = this.getHTML(); + + this.titleElement = this.shadowRoot.getElementById ("patch-title"); + this.parametersElement = this.shadowRoot.getElementById ("patch-parameters"); + } + + /** This is picked up by some of our wrapper code to know whether it makes + * sense to put a title bar/logo above the GUI. + */ + hasOwnTitleBar() + { + return true; + } + + //============================================================================== + /** @private */ + connectedCallback() + { + this.patchConnection.addStatusListener (this.statusListener); + this.patchConnection.requestStatusUpdate(); + } + + /** @private */ + disconnectedCallback() + { + this.patchConnection.removeStatusListener (this.statusListener); + } + + /** @private */ + createControlElements() + { + this.parametersElement.innerHTML = ""; + this.titleElement.innerText = this.status?.manifest?.name ?? "Cmajor"; + + for (const endpointInfo of this.status?.details?.inputs) + { + if (! endpointInfo.annotation?.hidden) + { + const control = Controls.createLabelledControl (this.patchConnection, endpointInfo); + + if (control) + this.parametersElement.appendChild (control); + } + } + } + + /** @private */ + getHTML() + { + return ` + + +
+
+ +

+
+
+
+
`; + } +} + +window.customElements.define ("cmaj-generic-patch-view", GenericPatchView); + +//============================================================================== +/** Creates a generic view element which can be used to control any patch. + * @param {PatchConnection} patchConnection - the connection to the target patch + */ +export default function createPatchView (patchConnection) +{ + return new GenericPatchView (patchConnection); +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-midi-helpers.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-midi-helpers.js new file mode 100644 index 00000000..1cc4933b --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-midi-helpers.js @@ -0,0 +1,181 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +export function getByte0 (message) { return (message >> 16) & 0xff; } +export function getByte1 (message) { return (message >> 8) & 0xff; } +export function getByte2 (message) { return message & 0xff; } + +function isVoiceMessage (message, type) { return ((message >> 16) & 0xf0) == type; } +function get14BitValue (message) { return getByte1 (message) | (getByte2 (message) << 7); } + +export function getChannel0to15 (message) { return getByte0 (message) & 0x0f; } +export function getChannel1to16 (message) { return getChannel0to15 (message) + 1; } + +export function getMessageSize (message) +{ + const mainGroupLengths = (3 << 0) | (3 << 2) | (3 << 4) | (3 << 6) + | (2 << 8) | (2 << 10) | (3 << 12); + + const lastGroupLengths = (1 << 0) | (2 << 2) | (3 << 4) | (2 << 6) + | (1 << 8) | (1 << 10) | (1 << 12) | (1 << 14) + | (1 << 16) | (1 << 18) | (1 << 20) | (1 << 22) + | (1 << 24) | (1 << 26) | (1 << 28) | (1 << 30); + + const firstByte = getByte0 (message); + const group = (firstByte >> 4) & 7; + + return (group != 7 ? (mainGroupLengths >> (2 * group)) + : (lastGroupLengths >> (2 * (firstByte & 15)))) & 3; +} + +export function isNoteOn (message) { return isVoiceMessage (message, 0x90) && getVelocity (message) != 0; } +export function isNoteOff (message) { return isVoiceMessage (message, 0x80) || (isVoiceMessage (message, 0x90) && getVelocity (message) == 0); } + +export function getNoteNumber (message) { return getByte1 (message); } +export function getVelocity (message) { return getByte2 (message); } + +export function isProgramChange (message) { return isVoiceMessage (message, 0xc0); } +export function getProgramChangeNumber (message) { return getByte1 (message); } +export function isPitchWheel (message) { return isVoiceMessage (message, 0xe0); } +export function getPitchWheelValue (message) { return get14BitValue (message); } +export function isAftertouch (message) { return isVoiceMessage (message, 0xa0); } +export function getAfterTouchValue (message) { return getByte2 (message); } +export function isChannelPressure (message) { return isVoiceMessage (message, 0xd0); } +export function getChannelPressureValue (message) { return getByte1 (message); } +export function isController (message) { return isVoiceMessage (message, 0xb0); } +export function getControllerNumber (message) { return getByte1 (message); } +export function getControllerValue (message) { return getByte2 (message); } +export function isControllerNumber (message, number) { return getByte1 (message) == number && isController (message); } +export function isAllNotesOff (message) { return isControllerNumber (message, 123); } +export function isAllSoundOff (message) { return isControllerNumber (message, 120); } +export function isQuarterFrame (message) { return getByte0 (message) == 0xf1; } +export function isClock (message) { return getByte0 (message) == 0xf8; } +export function isStart (message) { return getByte0 (message) == 0xfa; } +export function isContinue (message) { return getByte0 (message) == 0xfb; } +export function isStop (message) { return getByte0 (message) == 0xfc; } +export function isActiveSense (message) { return getByte0 (message) == 0xfe; } +export function isMetaEvent (message) { return getByte0 (message) == 0xff; } +export function isSongPositionPointer (message) { return getByte0 (message) == 0xf2; } +export function getSongPositionPointerValue (message) { return get14BitValue (message); } + +export function getChromaticScaleIndex (note) { return (note % 12) & 0xf; } +export function getOctaveNumber (note, octaveForMiddleC) { return ((Math.floor (note / 12) + (octaveForMiddleC ? octaveForMiddleC : 3)) & 0xff) - 5; } +export function getNoteName (note) { const names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithSharps (note) { const names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithFlats (note) { const names = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithOctaveNumber (note) { return getNoteName (note) + getOctaveNumber (note); } +export function isNatural (note) { const nats = [true, false, true, false, true, true, false, true, false, true, false, true]; return nats[getChromaticScaleIndex (note)]; } +export function isAccidental (note) { return ! isNatural (note); } + +export function printHexMIDIData (message) +{ + const numBytes = getMessageSize (message); + + if (numBytes == 0) + return "[empty]"; + + let s = ""; + + for (let i = 0; i < numBytes; ++i) + { + if (i != 0) s += ' '; + + const byte = message >> (16 - 8 * i) & 0xff; + s += "0123456789abcdef"[byte >> 4]; + s += "0123456789abcdef"[byte & 15]; + } + + return s; +} + +export function getMIDIDescription (message) +{ + const channelText = " Channel " + getChannel1to16 (message); + function getNote (m) { const s = getNoteNameWithOctaveNumber (getNoteNumber (message)); return s.length < 4 ? s + " " : s; }; + + if (isNoteOn (message)) return "Note-On: " + getNote (message) + channelText + " Velocity " + getVelocity (message); + if (isNoteOff (message)) return "Note-Off: " + getNote (message) + channelText + " Velocity " + getVelocity (message); + if (isAftertouch (message)) return "Aftertouch: " + getNote (message) + channelText + ": " + getAfterTouchValue (message); + if (isPitchWheel (message)) return "Pitch wheel: " + getPitchWheelValue (message) + ' ' + channelText; + if (isChannelPressure (message)) return "Channel pressure: " + getChannelPressureValue (message) + ' ' + channelText; + if (isController (message)) return "Controller:" + channelText + ": " + getControllerName (getControllerNumber (message)) + " = " + getControllerValue (message); + if (isProgramChange (message)) return "Program change: " + getProgramChangeNumber (message) + ' ' + channelText; + if (isAllNotesOff (message)) return "All notes off:" + channelText; + if (isAllSoundOff (message)) return "All sound off:" + channelText; + if (isQuarterFrame (message)) return "Quarter-frame"; + if (isClock (message)) return "Clock"; + if (isStart (message)) return "Start"; + if (isContinue (message)) return "Continue"; + if (isStop (message)) return "Stop"; + if (isMetaEvent (message)) return "Meta-event: type " + getByte1 (message); + if (isSongPositionPointer (message)) return "Song Position: " + getSongPositionPointerValue (message); + + return printHexMIDIData (message); +} + +export function getControllerName (controllerNumber) +{ + if (controllerNumber < 128) + { + const controllerNames = [ + "Bank Select", "Modulation Wheel (coarse)", "Breath controller (coarse)", undefined, + "Foot Pedal (coarse)", "Portamento Time (coarse)", "Data Entry (coarse)", "Volume (coarse)", + "Balance (coarse)", undefined, "Pan position (coarse)", "Expression (coarse)", + "Effect Control 1 (coarse)", "Effect Control 2 (coarse)", undefined, undefined, + "General Purpose Slider 1", "General Purpose Slider 2", "General Purpose Slider 3", "General Purpose Slider 4", + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "Bank Select (fine)", "Modulation Wheel (fine)", "Breath controller (fine)", undefined, + "Foot Pedal (fine)", "Portamento Time (fine)", "Data Entry (fine)", "Volume (fine)", + "Balance (fine)", undefined, "Pan position (fine)", "Expression (fine)", + "Effect Control 1 (fine)", "Effect Control 2 (fine)", undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "Hold Pedal", "Portamento", "Sustenuto Pedal", "Soft Pedal", + "Legato Pedal", "Hold 2 Pedal", "Sound Variation", "Sound Timbre", + "Sound Release Time", "Sound Attack Time", "Sound Brightness", "Sound Control 6", + "Sound Control 7", "Sound Control 8", "Sound Control 9", "Sound Control 10", + "General Purpose Button 1", "General Purpose Button 2", "General Purpose Button 3", "General Purpose Button 4", + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, "Reverb Level", + "Tremolo Level", "Chorus Level", "Celeste Level", "Phaser Level", + "Data Button increment", "Data Button decrement", "Non-registered Parameter (fine)", "Non-registered Parameter (coarse)", + "Registered Parameter (fine)", "Registered Parameter (coarse)", undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "All Sound Off", "All Controllers Off", "Local Keyboard", "All Notes Off", + "Omni Mode Off", "Omni Mode On", "Mono Operation", "Poly Operation" + ]; + + const name = controllerNames[controllerNumber]; + + if (name) + return name; + } + + return controllerNumber.toString(); +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-parameter-controls.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-parameter-controls.js new file mode 100644 index 00000000..c6290d05 --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-parameter-controls.js @@ -0,0 +1,844 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js"; + + +//============================================================================== +/** A base class for parameter controls, which automatically connects to a + * PatchConnection to monitor a parameter and provides methods to modify it. + */ +export class ParameterControlBase extends HTMLElement +{ + constructor() + { + super(); + + // prevent any clicks from focusing on this element + this.onmousedown = e => e.stopPropagation(); + } + + /** Attaches the control to a given PatchConnection and endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to, or pass + * undefined to disconnect the control. + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. + */ + setEndpoint (patchConnection, endpointInfo) + { + this.detachListener(); + + this.patchConnection = patchConnection; + this.endpointInfo = endpointInfo; + this.defaultValue = endpointInfo.annotation?.init || endpointInfo.defaultValue || 0; + + if (this.isConnected) + this.attachListener(); + } + + /** Override this method in a child class, and it will be called when the parameter value changes, + * so you can update the GUI appropriately. + */ + valueChanged (newValue) {} + + /** Your GUI can call this when it wants to change the parameter value. */ + setValue (value) { this.patchConnection?.sendEventOrValue (this.endpointInfo.endpointID, value); } + + /** Call this before your GUI begins a modification gesture. + * You might for example call this if the user begins a mouse-drag operation. + */ + beginGesture() { this.patchConnection?.sendParameterGestureStart (this.endpointInfo.endpointID); } + + /** Call this after your GUI finishes a modification gesture */ + endGesture() { this.patchConnection?.sendParameterGestureEnd (this.endpointInfo.endpointID); } + + /** This calls setValue(), but sandwiches it between some start/end gesture calls. + * You should use this to make sure a DAW correctly records automatiion for individual value changes + * that are not part of a gesture. + */ + setValueAsGesture (value) + { + this.beginGesture(); + this.setValue (value); + this.endGesture(); + } + + /** Resets the parameter to its default value */ + resetToDefault() + { + if (this.defaultValue !== null) + this.setValueAsGesture (this.defaultValue); + } + + //============================================================================== + /** @private */ + connectedCallback() + { + this.attachListener(); + } + + /** @protected */ + disconnectedCallback() + { + this.detachListener(); + } + + /** @private */ + detachListener() + { + if (this.listener) + { + this.patchConnection?.removeParameterListener?.(this.listener.endpointID, this.listener); + this.listener = undefined; + } + } + + /** @private */ + attachListener() + { + if (this.patchConnection && this.endpointInfo) + { + this.detachListener(); + + this.listener = newValue => this.valueChanged (newValue); + this.listener.endpointID = this.endpointInfo.endpointID; + + this.patchConnection.addParameterListener (this.endpointInfo.endpointID, this.listener); + this.patchConnection.requestParameterValue (this.endpointInfo.endpointID); + } + } +} + +//============================================================================== +/** A simple rotary parameter knob control. */ +export class Knob extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + this.innerHTML = ""; + this.className = "knob-container"; + const min = endpointInfo?.annotation?.min || 0; + const max = endpointInfo?.annotation?.max || 1; + + const createSvgElement = tag => window.document.createElementNS ("http://www.w3.org/2000/svg", tag); + + const svg = createSvgElement ("svg"); + svg.setAttribute ("viewBox", "0 0 100 100"); + + const trackBackground = createSvgElement ("path"); + trackBackground.setAttribute ("d", "M20,76 A 40 40 0 1 1 80 76"); + trackBackground.classList.add ("knob-path"); + trackBackground.classList.add ("knob-track-background"); + + const maxKnobRotation = 132; + const isBipolar = min + max === 0; + const dashLength = isBipolar ? 251.5 : 184; + const valueOffset = isBipolar ? 0 : 132; + this.getDashOffset = val => dashLength - 184 / (maxKnobRotation * 2) * (val + valueOffset); + + this.trackValue = createSvgElement ("path"); + + this.trackValue.setAttribute ("d", isBipolar ? "M50.01,10 A 40 40 0 1 1 50 10" + : "M20,76 A 40 40 0 1 1 80 76"); + this.trackValue.setAttribute ("stroke-dasharray", dashLength); + this.trackValue.classList.add ("knob-path"); + this.trackValue.classList.add ("knob-track-value"); + + this.dial = document.createElement ("div"); + this.dial.className = "knob-dial"; + + const dialTick = document.createElement ("div"); + dialTick.className = "knob-dial-tick"; + this.dial.appendChild (dialTick); + + svg.appendChild (trackBackground); + svg.appendChild (this.trackValue); + + this.appendChild (svg); + this.appendChild (this.dial); + + const remap = (source, sourceFrom, sourceTo, targetFrom, targetTo) => + (targetFrom + (source - sourceFrom) * (targetTo - targetFrom) / (sourceTo - sourceFrom)); + + const toValue = (knobRotation) => remap (knobRotation, -maxKnobRotation, maxKnobRotation, min, max); + this.toRotation = (value) => remap (value, min, max, -maxKnobRotation, maxKnobRotation); + + this.rotation = this.toRotation (this.defaultValue); + this.setRotation (this.rotation, true); + + const onMouseMove = (event) => + { + event.preventDefault(); // avoid scrolling whilst dragging + + const nextRotation = (rotation, delta) => + { + const clamp = (v, min, max) => Math.min (Math.max (v, min), max); + return clamp (rotation - delta, -maxKnobRotation, maxKnobRotation); + }; + + const workaroundBrowserIncorrectlyCalculatingMovementY = event.movementY === event.screenY; + const movementY = workaroundBrowserIncorrectlyCalculatingMovementY ? event.screenY - this.previousScreenY + : event.movementY; + this.previousScreenY = event.screenY; + + const speedMultiplier = event.shiftKey ? 0.25 : 1.5; + this.accumulatedRotation = nextRotation (this.accumulatedRotation, movementY * speedMultiplier); + this.setValue (toValue (this.accumulatedRotation)); + }; + + const onMouseUp = (event) => + { + this.previousScreenY = undefined; + this.accumulatedRotation = undefined; + window.removeEventListener ("mousemove", onMouseMove); + window.removeEventListener ("mouseup", onMouseUp); + this.endGesture(); + }; + + const onMouseDown = (event) => + { + this.previousScreenY = event.screenY; + this.accumulatedRotation = this.rotation; + this.beginGesture(); + window.addEventListener ("mousemove", onMouseMove); + window.addEventListener ("mouseup", onMouseUp); + event.preventDefault(); + }; + + const onTouchStart = (event) => + { + this.previousClientY = event.changedTouches[0].clientY; + this.accumulatedRotation = this.rotation; + this.touchIdentifier = event.changedTouches[0].identifier; + this.beginGesture(); + window.addEventListener ("touchmove", onTouchMove); + window.addEventListener ("touchend", onTouchEnd); + event.preventDefault(); + }; + + const onTouchMove = (event) => + { + for (const touch of event.changedTouches) + { + if (touch.identifier == this.touchIdentifier) + { + const nextRotation = (rotation, delta) => + { + const clamp = (v, min, max) => Math.min (Math.max (v, min), max); + return clamp (rotation - delta, -maxKnobRotation, maxKnobRotation); + }; + + const movementY = touch.clientY - this.previousClientY; + this.previousClientY = touch.clientY; + + const speedMultiplier = event.shiftKey ? 0.25 : 1.5; + this.accumulatedRotation = nextRotation (this.accumulatedRotation, movementY * speedMultiplier); + this.setValue (toValue (this.accumulatedRotation)); + } + } + }; + + const onTouchEnd = (event) => + { + this.previousClientY = undefined; + this.accumulatedRotation = undefined; + window.removeEventListener ("touchmove", onTouchMove); + window.removeEventListener ("touchend", onTouchEnd); + this.endGesture(); + }; + + this.addEventListener ("mousedown", onMouseDown); + this.addEventListener ("dblclick", () => this.resetToDefault()); + this.addEventListener ('touchstart', onTouchStart); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter"; + } + + /** @override */ + valueChanged (newValue) { this.setRotation (this.toRotation (newValue), false); } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return toFloatDisplayValueWithUnit (v, this.endpointInfo); } + + /** @private */ + setRotation (degrees, force) + { + if (force || this.rotation !== degrees) + { + this.rotation = degrees; + this.trackValue.setAttribute ("stroke-dashoffset", this.getDashOffset (this.rotation)); + this.dial.style.transform = `translate(-50%,-50%) rotate(${degrees}deg)`; + } + } + + /** @private */ + static getCSS() + { + return ` + .knob-container { + --knob-track-background-color: var(--background); + --knob-track-value-color: var(--foreground); + + --knob-dial-border-color: var(--foreground); + --knob-dial-background-color: var(--background); + --knob-dial-tick-color: var(--foreground); + + position: relative; + display: inline-block; + height: 5rem; + width: 5rem; + margin: 0; + padding: 0; + } + + .knob-path { + fill: none; + stroke-linecap: round; + stroke-width: 0.15rem; + } + + .knob-track-background { + stroke: var(--knob-track-background-color); + } + + .knob-track-value { + stroke: var(--knob-track-value-color); + } + + .knob-dial { + position: absolute; + text-align: center; + height: 60%; + width: 60%; + top: 50%; + left: 50%; + border: 0.15rem solid var(--knob-dial-border-color); + border-radius: 100%; + box-sizing: border-box; + transform: translate(-50%,-50%); + background-color: var(--knob-dial-background-color); + } + + .knob-dial-tick { + position: absolute; + display: inline-block; + + height: 1rem; + width: 0.15rem; + background-color: var(--knob-dial-tick-color); + }`; + } +} + +//============================================================================== +/** A boolean switch control */ +export class Switch extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + const outer = document.createElement ("div"); + outer.classList = "switch-outline"; + + const inner = document.createElement ("div"); + inner.classList = "switch-thumb"; + + this.innerHTML = ""; + this.currentValue = this.defaultValue > 0.5; + this.valueChanged (this.currentValue); + this.classList.add ("switch-container"); + + outer.appendChild (inner); + this.appendChild (outer); + this.addEventListener ("click", () => this.setValueAsGesture (this.currentValue ? 0 : 1.0)); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter" + && endpointInfo.annotation?.boolean; + } + + /** @override */ + valueChanged (newValue) + { + const b = newValue > 0.5; + this.currentValue = b; + this.classList.remove (! b ? "switch-on" : "switch-off"); + this.classList.add (b ? "switch-on" : "switch-off"); + } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return `${v > 0.5 ? "On" : "Off"}`; } + + /** @private */ + static getCSS() + { + return ` + .switch-container { + --switch-outline-color: var(--foreground); + --switch-thumb-color: var(--foreground); + --switch-on-background-color: var(--background); + --switch-off-background-color: var(--background); + + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + } + + .switch-outline { + position: relative; + display: inline-block; + height: 1.25rem; + width: 2.5rem; + border-radius: 10rem; + box-shadow: 0 0 0 0.15rem var(--switch-outline-color); + transition: background-color 0.1s cubic-bezier(0.5, 0, 0.2, 1); + } + + .switch-thumb { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + height: 1rem; + width: 1rem; + background-color: var(--switch-thumb-color); + border-radius: 100%; + transition: left 0.1s cubic-bezier(0.5, 0, 0.2, 1); + } + + .switch-off .switch-thumb { + left: 25%; + background: none; + border: var(--switch-thumb-color) solid 0.1rem; + height: 0.8rem; + width: 0.8rem; + } + .switch-on .switch-thumb { + left: 75%; + } + + .switch-off .switch-outline { + background-color: var(--switch-on-background-color); + } + .switch-on .switch-outline { + background-color: var(--switch-off-background-color); + }`; + } +} + +//============================================================================== +function toFloatDisplayValueWithUnit (v, endpointInfo) +{ + return `${v.toFixed (2)} ${endpointInfo.annotation?.unit ?? ""}`; +} + +//============================================================================== +/** A control that allows an item to be selected from a drop-down list of options */ +export class Options extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + const toValue = (min, step, index) => min + (step * index); + const toStepCount = count => count > 0 ? count - 1 : 1; + + const { min, max, options } = (() => + { + if (Options.hasTextOptions (endpointInfo)) + { + const optionList = endpointInfo.annotation.text.split ("|"); + const stepCount = toStepCount (optionList.length); + let min = 0, max = stepCount, step = 1; + + if (endpointInfo.annotation.min != null && endpointInfo.annotation.max != null) + { + min = endpointInfo.annotation.min; + max = endpointInfo.annotation.max; + step = (max - min) / stepCount; + } + + const options = optionList.map ((text, index) => ({ value: toValue (min, step, index), text })); + + return { min, max, options }; + } + + if (Options.isExplicitlyDiscrete (endpointInfo)) + { + const step = endpointInfo.annotation.step; + + const min = endpointInfo.annotation?.min || 0; + const max = endpointInfo.annotation?.max || 1; + + const numDiscreteOptions = (((max - min) / step) | 0) + 1; + + const options = new Array (numDiscreteOptions); + for (let i = 0; i < numDiscreteOptions; ++i) + { + const value = toValue (min, step, i); + options[i] = { value, text: toFloatDisplayValueWithUnit (value, endpointInfo) }; + } + + return { min, max, options }; + } + })(); + + this.options = options; + + const stepCount = toStepCount (this.options.length); + const normalise = value => (value - min) / (max - min); + this.toIndex = value => Math.min (stepCount, normalise (value) * this.options.length) | 0; + + this.innerHTML = ""; + + this.select = document.createElement ("select"); + + for (const option of this.options) + { + const optionElement = document.createElement ("option"); + optionElement.innerText = option.text; + this.select.appendChild (optionElement); + } + + this.selectedIndex = this.toIndex (this.defaultValue); + + this.select.addEventListener ("change", (e) => + { + const newIndex = e.target.selectedIndex; + + // prevent local state change. the caller will update us when the backend actually applies the update + e.target.selectedIndex = this.selectedIndex; + + this.setValueAsGesture (this.options[newIndex].value) + }); + + this.valueChanged (this.selectedIndex); + + this.className = "select-container"; + this.appendChild (this.select); + + const icon = document.createElement ("span"); + icon.className = "select-icon"; + this.appendChild (icon); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter" + && (this.hasTextOptions (endpointInfo) || this.isExplicitlyDiscrete (endpointInfo)); + } + + /** @override */ + valueChanged (newValue) + { + const index = this.toIndex (newValue); + this.selectedIndex = index; + this.select.selectedIndex = index; + } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return this.options[this.toIndex(v)].text; } + + /** @private */ + static hasTextOptions (endpointInfo) + { + return endpointInfo.annotation?.text?.split?.("|").length > 1 + } + + /** @private */ + static isExplicitlyDiscrete (endpointInfo) + { + return endpointInfo.annotation?.discrete && endpointInfo.annotation?.step > 0; + } + + /** @private */ + static getCSS() + { + return ` + .select-container { + position: relative; + display: block; + font-size: 0.8rem; + width: 100%; + color: var(--foreground); + border: 0.15rem solid var(--foreground); + border-radius: 0.6rem; + margin: 0; + padding: 0; + } + + select { + background: none; + appearance: none; + -webkit-appearance: none; + font-family: inherit; + font-size: 0.8rem; + + overflow: hidden; + text-overflow: ellipsis; + + padding: 0 1.5rem 0 0.6rem; + + outline: none; + color: var(--foreground); + height: 2rem; + box-sizing: border-box; + margin: 0; + border: none; + + width: 100%; + } + + select option { + background: var(--background); + color: var(--foreground); + } + + .select-icon { + position: absolute; + right: 0.3rem; + top: 0.5rem; + pointer-events: none; + background-color: var(--foreground); + width: 1.4em; + height: 1.4em; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z'/%3E%3C/svg%3E"); + mask-repeat: no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z'/%3E%3C/svg%3E"); + -webkit-mask-repeat: no-repeat; + }`; + } +} + +//============================================================================== +/** A control which wraps a child control, adding a label and value display box below it */ +export class LabelledControlHolder extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo, childControl) + { + super(); + this.childControl = childControl; + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + this.innerHTML = ""; + this.className = "labelled-control"; + + const centeredControl = document.createElement ("div"); + centeredControl.className = "labelled-control-centered-control"; + + centeredControl.appendChild (this.childControl); + + const titleValueHoverContainer = document.createElement ("div"); + titleValueHoverContainer.className = "labelled-control-label-container"; + + const nameText = document.createElement ("div"); + nameText.classList.add ("labelled-control-name"); + nameText.innerText = endpointInfo.annotation?.name || endpointInfo.name || endpointInfo.endpointID || ""; + + this.valueText = document.createElement ("div"); + this.valueText.classList.add ("labelled-control-value"); + + titleValueHoverContainer.appendChild (nameText); + titleValueHoverContainer.appendChild (this.valueText); + + this.appendChild (centeredControl); + this.appendChild (titleValueHoverContainer); + } + + /** @override */ + valueChanged (newValue) + { + this.valueText.innerText = this.childControl?.getDisplayValue (newValue); + } + + /** @private */ + static getCSS() + { + return ` + .labelled-control { + --labelled-control-font-color: var(--foreground); + --labelled-control-font-size: 0.8rem; + + position: relative; + display: inline-block; + margin: 0 0.4rem 0.4rem; + vertical-align: top; + text-align: left; + padding: 0; + } + + .labelled-control-centered-control { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + width: 5.5rem; + height: 5rem; + } + + .labelled-control-label-container { + position: relative; + display: block; + max-width: 5.5rem; + margin: -0.4rem auto 0.4rem; + text-align: center; + font-size: var(--labelled-control-font-size); + color: var(--labelled-control-font-color); + cursor: default; + } + + .labelled-control-name { + overflow: hidden; + text-overflow: ellipsis; + } + + .labelled-control-value { + position: absolute; + top: 0; + left: 0; + right: 0; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + } + + .labelled-control:hover .labelled-control-name, + .labelled-control:active .labelled-control-name { + opacity: 0; + } + .labelled-control:hover .labelled-control-value, + .labelled-control:active .labelled-control-value { + opacity: 1; + }`; + } +} + +window.customElements.define ("cmaj-knob-control", Knob); +window.customElements.define ("cmaj-switch-control", Switch); +window.customElements.define ("cmaj-options-control", Options); +window.customElements.define ("cmaj-labelled-control-holder", LabelledControlHolder); + +//============================================================================== +/** Fetches all the CSS for the controls defined in this module */ +export function getAllCSS() +{ + return ` + ${Options.getCSS()} + ${Knob.getCSS()} + ${Switch.getCSS()} + ${LabelledControlHolder.getCSS()}`; +} + +//============================================================================== +/** Creates a suitable control for the given endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. +*/ +export function createControl (patchConnection, endpointInfo) +{ + if (Switch.canBeUsedFor (endpointInfo)) + return new Switch (patchConnection, endpointInfo); + + if (Options.canBeUsedFor (endpointInfo)) + return new Options (patchConnection, endpointInfo); + + if (Knob.canBeUsedFor (endpointInfo)) + return new Knob (patchConnection, endpointInfo); + + return undefined; +} + +//============================================================================== +/** Creates a suitable labelled control for the given endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. +*/ +export function createLabelledControl (patchConnection, endpointInfo) +{ + const control = createControl (patchConnection, endpointInfo); + + if (control) + return new LabelledControlHolder (patchConnection, endpointInfo, control); + + return undefined; +} + +//============================================================================== +/** Takes a patch connection and its current status object, and tries to create + * a control for the given endpoint ID. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} status - the connection's current status + * @param {string} endpointID - the endpoint you'd like to control + */ +export function createLabelledControlForEndpointID (patchConnection, status, endpointID) +{ + for (const endpointInfo of status?.details?.inputs) + if (endpointInfo.endpointID == endpointID) + return createLabelledControl (patchConnection, endpointInfo); + + return undefined; +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-connection.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-connection.js new file mode 100644 index 00000000..2fff73c5 --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-connection.js @@ -0,0 +1,215 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { EventListenerList } from "./cmaj-event-listener-list.js" + +//============================================================================== +/** This class implements the API and much of the logic for communicating with + * an instance of a patch that is running. + */ +export class PatchConnection extends EventListenerList +{ + constructor() + { + super(); + } + + //============================================================================== + // Status-handling methods: + + /** Calling this will trigger an asynchronous callback to any status listeners with the + * patch's current state. Use addStatusListener() to attach a listener to receive it. + */ + requestStatusUpdate() { this.sendMessageToServer ({ type: "req_status" }); } + + /** Attaches a listener function that will be called whenever the patch's status changes. + * The function will be called with a parameter object containing many properties describing the status, + * including whether the patch is loaded, any errors, endpoint descriptions, its manifest, etc. + */ + addStatusListener (listener) { this.addEventListener ("status", listener); } + + /** Removes a listener that was previously added with addStatusListener() + */ + removeStatusListener (listener) { this.removeEventListener ("status", listener); } + + /** Causes the patch to be reset to its "just loaded" state. */ + resetToInitialState() { this.sendMessageToServer ({ type: "req_reset" }); } + + //============================================================================== + // Methods for sending data to input endpoints: + + /** Sends a value to one of the patch's input endpoints. + * + * This can be used to send a value to either an 'event' or 'value' type input endpoint. + * If the endpoint is a 'value' type, then the rampFrames parameter can optionally be used to specify + * the number of frames over which the current value should ramp to the new target one. + * The value parameter will be coerced to the type that is expected by the endpoint. So for + * examples, numbers will be converted to float or integer types, javascript objects and arrays + * will be converted into more complex types in as good a fashion is possible. + */ + sendEventOrValue (endpointID, value, rampFrames, timeoutMillisecs) { this.sendMessageToServer ({ type: "send_value", id: endpointID, value, rampFrames, timeout: timeoutMillisecs }); } + + /** Sends a short MIDI message value to a MIDI endpoint. + * The value must be a number encoded with `(byte0 << 16) | (byte1 << 8) | byte2`. + */ + sendMIDIInputEvent (endpointID, shortMIDICode) { this.sendEventOrValue (endpointID, { message: shortMIDICode }); } + + /** Tells the patch that a series of changes that constitute a gesture is about to take place + * for the given endpoint. Remember to call sendParameterGestureEnd() after they're done! + */ + sendParameterGestureStart (endpointID) { this.sendMessageToServer ({ type: "send_gesture_start", id: endpointID }); } + + /** Tells the patch that a gesture started by sendParameterGestureStart() has finished. + */ + sendParameterGestureEnd (endpointID) { this.sendMessageToServer ({ type: "send_gesture_end", id: endpointID }); } + + //============================================================================== + // Stored state control methods: + + /** Requests a callback to any stored-state value listeners with the current value of a given key-value pair. + * To attach a listener to receive these events, use addStoredStateValueListener(). + * @param {string} key + */ + requestStoredStateValue (key) { this.sendMessageToServer ({ type: "req_state_value", key: key }); } + + /** Modifies a key-value pair in the patch's stored state. + * @param {string} key + * @param {Object} newValue + */ + sendStoredStateValue (key, newValue) { this.sendMessageToServer ({ type: "send_state_value", key: key, value: newValue }); } + + /** Attaches a listener function that will be called when any key-value pair in the stored state is changed. + * The listener function will receive a message parameter with properties 'key' and 'value'. + */ + addStoredStateValueListener (listener) { this.addEventListener ("state_key_value", listener); } + + /** Removes a listener that was previously added with addStoredStateValueListener(). + */ + removeStoredStateValueListener (listener) { this.removeEventListener ("state_key_value", listener); } + + /** Applies a complete stored state to the patch. + * To get the current complete state, use requestFullStoredState(). + */ + sendFullStoredState (fullState) { this.sendMessageToServer ({ type: "send_full_state", value: fullState }); } + + /** Asynchronously requests the full stored state of the patch. + * The listener function that is supplied will be called asynchronously with the state as its argument. + */ + requestFullStoredState (callback) + { + const replyType = "fullstate_response_" + (Math.floor (Math.random() * 100000000)).toString(); + this.addSingleUseListener (replyType, callback); + this.sendMessageToServer ({ type: "req_full_state", replyType: replyType }); + } + + //============================================================================== + // Listener methods: + + /** Attaches a listener function that will receive updates with the events or audio data + * that is being sent or received by an endpoint. + * + * If the endpoint is an event or value, the callback will be given an argument which is + * the new value. + * + * If the endpoint has the right shape to be treated as "audio" then the callback will receive + * a stream of updates of the min/max range of chunks of data that is flowing through it. + * There will be one callback per chunk of data, and the size of chunks is specified by + * the optional granularity parameter. + * + * @param {string} endpointID + * @param {number} granularity - if defined, this specifies the number of frames per callback + * @param {boolean} sendFullAudioData - if false, the listener will receive an argument object containing + * two properties 'min' and 'max', which are each an array of values, one element per audio + * channel. This allows you to find the highest and lowest samples in that chunk for each channel. + * If sendFullAudioData is true, the listener's argument will have a property 'data' which is an + * array containing one array per channel of raw audio samples data. + */ + addEndpointListener (endpointID, listener, granularity, sendFullAudioData) + { + listener.eventID = "event_" + endpointID + "_" + (Math.floor (Math.random() * 100000000)).toString(); + this.addEventListener (listener.eventID, listener); + this.sendMessageToServer ({ type: "add_endpoint_listener", endpoint: endpointID, replyType: + listener.eventID, granularity: granularity, fullAudioData: sendFullAudioData }); + } + + /** Removes a listener that was previously added with addEndpointListener() + * @param {string} endpointID + */ + removeEndpointListener (endpointID, listener) + { + this.removeEventListener (listener.eventID, listener); + this.sendMessageToServer ({ type: "remove_endpoint_listener", endpoint: endpointID, replyType: listener.eventID }); + } + + /** This will trigger an asynchronous callback to any parameter listeners that are + * attached, providing them with its up-to-date current value for the given endpoint. + * Use addAllParameterListener() to attach a listener to receive the result. + * @param {string} endpointID + */ + requestParameterValue (endpointID) { this.sendMessageToServer ({ type: "req_param_value", id: endpointID }); } + + /** Attaches a listener function which will be called whenever the value of a specific parameter changes. + * The listener function will be called with an argument which is the new value. + * @param {string} endpointID + */ + addParameterListener (endpointID, listener) { this.addEventListener ("param_value_" + endpointID.toString(), listener); } + + /** Removes a listener that was previously added with addParameterListener() + * @param {string} endpointID + */ + removeParameterListener (endpointID, listener) { this.removeEventListener ("param_value_" + endpointID.toString(), listener); } + + /** Attaches a listener function which will be called whenever the value of any parameter changes in the patch. + * The listener function will be called with an argument object with the fields 'endpointID' and 'value'. + */ + addAllParameterListener (listener) { this.addEventListener ("param_value", listener); } + + /** Removes a listener that was previously added with addAllParameterListener() + */ + removeAllParameterListener (listener) { this.removeEventListener ("param_value", listener); } + + /** This takes a relative path to an asset within the patch bundle, and converts it to a + * path relative to the root of the browser that is showing the view. + * + * You need you use this in your view code to translate your asset URLs to a form that + * can be safely used in your view's HTML DOM (e.g. in its CSS). This is needed because the + * host's HTTP server (which is delivering your view pages) may have a different '/' root + * than the root of your patch (e.g. if a single server is serving multiple patch GUIs). + * + * @param {string} path + */ + getResourceAddress (path) { return path; } + + //============================================================================== + // Private methods follow this point.. + + /** @private */ + deliverMessageFromServer (msg) + { + if (msg.type === "status") + this.manifest = msg.message?.manifest; + + if (msg.type == "param_value") + this.dispatchEvent ("param_value_" + msg.message.endpointID, msg.message.value); + + this.dispatchEvent (msg.type, msg.message); + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-view.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-view.js new file mode 100644 index 00000000..8052a30e --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-patch-view.js @@ -0,0 +1,125 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js" + +/** Returns a list of types of view that can be created for this patch. + */ +export function getAvailableViewTypes (patchConnection) +{ + if (! patchConnection) + return []; + + if (patchConnection.manifest?.view?.src) + return ["custom", "generic"]; + + return ["generic"]; +} + +/** Creates and returns a HTMLElement view which can be shown to control this patch. + * + * If no preferredType argument is supplied, this will return either a custom patch-specific + * view (if the manifest specifies one), or a generic view if not. The preferredType argument + * can be used to choose one of the types of view returned by getAvailableViewTypes(). + * + * @param {PatchConnection} patchConnection - the connection to use + * @param {string} preferredType - the name of the type of view to open, e.g. "generic" + * or the name of one of the views in the manifest + * @returns {HTMLElement} a HTMLElement that can be displayed as the patch GUI + */ +export async function createPatchView (patchConnection, preferredType) +{ + if (patchConnection?.manifest) + { + let view = patchConnection.manifest.view; + + if (view && preferredType === "generic") + if (view.src) + view = undefined; + + const viewModuleURL = view?.src ? patchConnection.getResourceAddress (view.src) : "./cmaj-generic-patch-view.js"; + const viewModule = await import (viewModuleURL); + const patchView = await viewModule?.default (patchConnection); + + if (patchView) + { + patchView.style.display = "block"; + + if (view?.width > 10) + patchView.style.width = view.width + "px"; + else + patchView.style.width = undefined; + + if (view?.height > 10) + patchView.style.height = view.height + "px"; + else + patchView.style.height = undefined; + + return patchView; + } + } + + return undefined; +} + +/** If a patch view declares itself to be scalable, this will attempt to scale it to fit + * into a given parent element. + * + * @param {HTMLElement} view - the patch view + * @param {HTMLElement} parentToScale - the patch view's direct parent element, to which + * the scale factor will be applied + * @param {HTMLElement} parentContainerToFitTo - an outer parent of the view, whose bounds + * the view will be made to fit + */ +export function scalePatchViewToFit (view, parentToScale, parentContainerToFitTo) +{ + function getClientSize (view) + { + const clientStyle = getComputedStyle (view); + + return { + width: view.clientHeight - parseFloat (clientStyle.paddingTop) - parseFloat (clientStyle.paddingBottom), + height: view.clientWidth - parseFloat (clientStyle.paddingLeft) - parseFloat (clientStyle.paddingRight) + }; + } + + const scaleLimits = view.getScaleFactorLimits?.(); + + if (scaleLimits && (scaleLimits.minScale || scaleLimits.maxScale)) + { + const minScale = scaleLimits.minScale || 0.25; + const maxScale = scaleLimits.maxScale || 5.0; + + const targetSize = getClientSize (parentContainerToFitTo); + const clientSize = getClientSize (view); + + const scaleW = targetSize.width / clientSize.width; + const scaleH = targetSize.height / clientSize.height; + + const scale = Math.min (maxScale, Math.max (minScale, Math.min (scaleW, scaleH))); + + parentToScale.style.transform = `scale(${scale})`; + } + else + { + parentToScale.style.transform = "none"; + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-piano-keyboard.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-piano-keyboard.js new file mode 100644 index 00000000..23ef7a1b --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-piano-keyboard.js @@ -0,0 +1,460 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 The Cmajor Toolkit +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// The Cmajor project is subject to commercial or open-source licensing. +// You may use it under the terms of the GPLv3 (see www.gnu.org/licenses), or +// visit https://cmajor.dev to learn about our commercial licence options. +// +// CMAJOR IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER +// EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE +// DISCLAIMED. + +import * as midi from "./cmaj-midi-helpers.js" + +/** + * An general-purpose on-screen piano keyboard component that allows clicks or + * key-presses to be used to play things. + * + * To receive events, you can attach "note-down" and "note-up" event listeners via + * the standard HTMLElement/EventTarget event system, e.g. + * + * myKeyboardElement.addEventListener("note-down", (note) => { ...handle note on... }); + * myKeyboardElement.addEventListener("note-up", (note) => { ...handle note off... }); + * + * The `note` object will contain a `note` property with the MIDI note number. + * (And obviously you can remove them with removeEventListener) + * + * Or, if you're connecting the keyboard to a PatchConnection, you can use the helper + * method attachToPatchConnection() to create and attach some suitable listeners. + * + */ +export default class PianoKeyboard extends HTMLElement +{ + constructor ({ naturalNoteWidth, + accidentalWidth, + accidentalPercentageHeight, + naturalNoteBorder, + accidentalNoteBorder, + pressedNoteColour } = {}) + { + super(); + + this.naturalWidth = naturalNoteWidth || 20; + this.accidentalWidth = accidentalWidth || 12; + this.accidentalPercentageHeight = accidentalPercentageHeight || 66; + this.naturalBorder = naturalNoteBorder || "2px solid #333"; + this.accidentalBorder = accidentalNoteBorder || "2px solid #333"; + this.pressedColour = pressedNoteColour || "#8ad"; + + this.root = this.attachShadow({ mode: "open" }); + + this.root.addEventListener ("mousedown", (event) => this.handleMouse (event, true, false) ); + this.root.addEventListener ("mouseup", (event) => this.handleMouse (event, false, true) ); + this.root.addEventListener ("mousemove", (event) => this.handleMouse (event, false, false) ); + this.root.addEventListener ("mouseenter", (event) => this.handleMouse (event, false, false) ); + this.root.addEventListener ("mouseout", (event) => this.handleMouse (event, false, false) ); + + this.addEventListener ("keydown", (event) => this.handleKey (event, true)); + this.addEventListener ("keyup", (event) => this.handleKey (event, false)); + this.addEventListener ("focusout", (event) => this.allNotesOff()); + + this.currentDraggedNote = -1; + this.currentExternalNotesOn = new Set(); + this.currentKeyboardNotes = new Set(); + this.currentPlayedNotes = new Set(); + this.currentDisplayedNotes = new Set(); + this.notes = []; + this.currentTouches = new Map(); + + this.refreshHTML(); + + for (let child of this.root.children) + { + child.addEventListener ("touchstart", (event) => this.touchStart (event) ); + child.addEventListener ("touchend", (event) => this.touchEnd (event) ); + } + } + + static get observedAttributes() + { + return ["root-note", "note-count", "key-map"]; + } + + get config() + { + return { + rootNote: parseInt(this.getAttribute("root-note") || "36"), + numNotes: parseInt(this.getAttribute("note-count") || "61"), + keymap: this.getAttribute("key-map") || "KeyA KeyW KeyS KeyE KeyD KeyF KeyT KeyG KeyY KeyH KeyU KeyJ KeyK KeyO KeyL KeyP Semicolon", + }; + } + + /** This attaches suitable listeners to make this keyboard control the given MIDI + * endpoint of a PatchConnection object. Use detachPatchConnection() to remove + * a connection later on. + * + * @param {PatchConnection} patchConnection + * @param {string} midiInputEndpointID + */ + attachToPatchConnection (patchConnection, midiInputEndpointID) + { + const velocity = 100; + + const callbacks = { + noteDown: e => patchConnection.sendMIDIInputEvent (midiInputEndpointID, 0x900000 | (e.detail.note << 8) | velocity), + noteUp: e => patchConnection.sendMIDIInputEvent (midiInputEndpointID, 0x800000 | (e.detail.note << 8) | velocity), + midiIn: e => this.handleExternalMIDI (e.message), + midiInputEndpointID + }; + + if (! this.callbacks) + this.callbacks = new Map(); + + this.callbacks.set (patchConnection, callbacks); + + this.addEventListener ("note-down", callbacks.noteDown); + this.addEventListener ("note-up", callbacks.noteUp); + patchConnection.addEndpointListener (midiInputEndpointID, callbacks.midiIn); + } + + /** This removes the connection to a PatchConnection object that was previously attached + * with attachToPatchConnection(). + * + * @param {PatchConnection} patchConnection + */ + detachPatchConnection (patchConnection) + { + const callbacks = this.callbacks.get (patchConnection); + + if (callbacks) + { + this.removeEventListener ("note-down", callbacks.noteDown); + this.removeEventListener ("note-up", callbacks.noteUp); + patchConnection.removeEndpointListener (callbacks.midiInputEndpointID, callbacks.midiIn); + } + + this.callbacks[patchConnection] = undefined; + } + + //============================================================================== + /** Can be overridden to return the color to use for a note index */ + getNoteColour (note) { return undefined; } + + /** Can be overridden to return the text label to draw on a note index */ + getNoteLabel (note) { return midi.getChromaticScaleIndex (note) == 0 ? midi.getNoteNameWithOctaveNumber (note) : ""; } + + /** Clients should call this to deliver a MIDI message, which the keyboard will use to + * highlight the notes that are currently playing. + */ + handleExternalMIDI (message) + { + if (midi.isNoteOn (message)) + { + const note = midi.getNoteNumber (message); + this.currentExternalNotesOn.add (note); + this.refreshActiveNoteElements(); + } + else if (midi.isNoteOff (message)) + { + const note = midi.getNoteNumber (message); + this.currentExternalNotesOn.delete (note); + this.refreshActiveNoteElements(); + } + } + + /** This method will be called when the user plays a note. The default behaviour is + * to dispath an event, but you could override this if you needed to. + */ + sendNoteOn (note) { this.dispatchEvent (new CustomEvent('note-down', { detail: { note: note }})); } + + /** This method will be called when the user releases a note. The default behaviour is + * to dispath an event, but you could override this if you needed to. + */ + sendNoteOff (note) { this.dispatchEvent (new CustomEvent('note-up', { detail: { note: note } })); } + + /** Clients can call this to force all the notes to turn off, e.g. in a "panic". */ + allNotesOff() + { + this.setDraggedNote (-1); + + for (let note of this.currentKeyboardNotes.values()) + this.removeKeyboardNote (note); + + this.currentExternalNotesOn.clear(); + this.refreshActiveNoteElements(); + } + + setDraggedNote (newNote) + { + if (newNote != this.currentDraggedNote) + { + if (this.currentDraggedNote >= 0) + this.sendNoteOff (this.currentDraggedNote); + + this.currentDraggedNote = newNote; + + if (this.currentDraggedNote >= 0) + this.sendNoteOn (this.currentDraggedNote); + + this.refreshActiveNoteElements(); + } + } + + addKeyboardNote (note) + { + if (! this.currentKeyboardNotes.has (note)) + { + this.sendNoteOn (note); + this.currentKeyboardNotes.add (note); + this.refreshActiveNoteElements(); + } + } + + removeKeyboardNote (note) + { + if (this.currentKeyboardNotes.has (note)) + { + this.sendNoteOff (note); + this.currentKeyboardNotes.delete (note); + this.refreshActiveNoteElements(); + } + } + + isNoteActive (note) + { + return note == this.currentDraggedNote + || this.currentExternalNotesOn.has (note) + || this.currentKeyboardNotes.has (note); + } + + //============================================================================== + /** @private */ + touchEnd (event) + { + for (const touch of event.changedTouches) + { + const note = this.currentTouches.get (touch.identifier); + this.currentTouches.delete (touch.identifier); + this.removeKeyboardNote (note); + } + + event.preventDefault(); + } + + /** @private */ + touchStart (event) + { + for (const touch of event.changedTouches) + { + const note = touch.target.id.substring (4); + this.currentTouches.set (touch.identifier, note); + this.addKeyboardNote (note); + } + + event.preventDefault(); + } + + /** @private */ + handleMouse (event, isDown, isUp) + { + if (isDown) + this.isDragging = true; + + if (this.isDragging) + { + let newActiveNote = -1; + + if (event.buttons != 0 && event.type != "mouseout") + { + const note = event.target.id.substring (4); + + if (note !== undefined) + newActiveNote = parseInt (note); + } + + this.setDraggedNote (newActiveNote); + + if (! isDown) + event.preventDefault(); + } + + if (isUp) + this.isDragging = false; + } + + /** @private */ + handleKey (event, isDown) + { + const config = this.config; + const index = config.keymap.split (" ").indexOf (event.code); + + if (index >= 0) + { + const note = Math.floor ((config.rootNote + (config.numNotes / 4) + 11) / 12) * 12 + index; + + if (isDown) + this.addKeyboardNote (note); + else + this.removeKeyboardNote (note); + + event.preventDefault(); + } + } + + /** @private */ + refreshHTML() + { + this.root.innerHTML = `${this.getNoteElements()}`; + + for (let i = 0; i < 128; ++i) + { + const elem = this.shadowRoot.getElementById (`note${i.toString()}`); + this.notes.push ({ note: i, element: elem }); + } + + this.style.maxWidth = window.getComputedStyle (this).scrollWidth; + } + + /** @private */ + refreshActiveNoteElements() + { + for (let note of this.notes) + { + if (note.element) + { + if (this.isNoteActive (note.note)) + note.element.classList.add ("active"); + else + note.element.classList.remove ("active"); + } + } + } + + /** @private */ + getAccidentalOffset (note) + { + let index = midi.getChromaticScaleIndex (note); + + let negativeOffset = -this.accidentalWidth / 16; + let positiveOffset = 3 * this.accidentalWidth / 16; + + const accOffset = this.naturalWidth - (this.accidentalWidth / 2); + const offsets = [ 0, negativeOffset, 0, positiveOffset, 0, 0, negativeOffset, 0, 0, 0, positiveOffset, 0 ]; + + return accOffset + offsets[index]; + } + + /** @private */ + getNoteElements() + { + const config = this.config; + let naturals = "", accidentals = ""; + let x = 0; + + for (let i = 0; i < config.numNotes; ++i) + { + const note = config.rootNote + i; + const name = this.getNoteLabel (note); + + if (midi.isNatural (note)) + { + naturals += `

${name}

`; + } + else + { + let accidentalOffset = this.getAccidentalOffset (note); + accidentals += `
`; + } + + if (midi.isNatural (note + 1) || i == config.numNotes - 1) + x += this.naturalWidth; + } + + this.style.maxWidth = (x + 1) + "px"; + + return `
+ ${naturals} + ${accidentals} +
`; + } + + /** @private */ + getCSS() + { + let extraColours = ""; + const config = this.config; + + for (let i = 0; i < config.numNotes; ++i) + { + const note = config.rootNote + i; + const colourOverride = this.getNoteColour (note); + + if (colourOverride) + extraColours += `#note${note}:not(.active) { background: ${colourOverride}; }`; + } + + return ` + * { + box-sizing: border-box; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + margin: 0; + padding: 0; + } + + :host { + display: block; + overflow: auto; + position: relative; + } + + .natural-note { + position: absolute; + border: ${this.naturalBorder}; + background: #fff; + width: ${this.naturalWidth}px; + height: 100%; + + display: flex; + align-items: end; + justify-content: center; + } + + p { + pointer-events: none; + text-align: center; + font-size: 0.7rem; + color: grey; + } + + .accidental-note { + position: absolute; + top: 0; + border: ${this.accidentalBorder}; + background: #333; + width: ${this.accidentalWidth}px; + height: ${this.accidentalPercentageHeight}%; + } + + .note-holder { + position: relative; + height: 100%; + } + + .active { + background: ${this.pressedColour}; + } + + ${extraColours} + ` + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj-server-session.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-server-session.js new file mode 100644 index 00000000..813f5fcd --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj-server-session.js @@ -0,0 +1,452 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js" +import { EventListenerList } from "./cmaj-event-listener-list.js" + + +//============================================================================== +/* + * This class provides the API and manages the communication protocol between + * a javascript application and a Cmajor session running on some kind of server + * (which may be local or remote). + * + * This is an abstract base class: some kind of transport layer will create a + * subclass of ServerSession which a client application can then use to control + * and interact with the server. + */ +export class ServerSession extends EventListenerList +{ + /** A server session must be given a unique sessionID. + * @param {string} sessionID - this must be a unique string which is safe for + * use as an identifier or filename + */ + constructor (sessionID) + { + super(); + + this.sessionID = sessionID; + this.activePatchConnections = new Set(); + this.status = { connected: false, loaded: false }; + this.lastServerMessageTime = Date.now(); + this.checkForServerTimer = setInterval (() => this.checkServerStillExists(), 2000); + } + + /** Call `dispose()` when this session is no longer needed and should be released. */ + dispose() + { + if (this.checkForServerTimer) + { + clearInterval (this.checkForServerTimer); + this.checkForServerTimer = undefined; + } + + this.status = { connected: false, loaded: false }; + } + + //============================================================================== + // Session status methods: + + /** Attaches a listener function which will be called when the session status changes. + * The listener will be called with an argument object containing lots of properties + * describing the state, including any errors, loaded patch manifest, etc. + */ + addStatusListener (listener) { this.addEventListener ("session_status", listener); } + + /** Removes a listener that was previously added by `addStatusListener()` + */ + removeStatusListener (listener) { this.removeEventListener ("session_status", listener); } + + /** Asks the server to asynchronously send a status update message with the latest status. + */ + requestSessionStatus() { this.sendMessageToServer ({ type: "req_session_status" }); } + + /** Returns the session's last known status object. */ + getCurrentStatus() { return this.status; } + + //============================================================================== + // Patch loading: + + /** Asks the server to load the specified patch into our session. + */ + loadPatch (patchFileToLoad) + { + this.currentPatchLocation = patchFileToLoad; + this.sendMessageToServer ({ type: "load_patch", file: patchFileToLoad }); + } + + /** Tells the server to asynchronously generate a list of patches that it has access to. + * The function provided will be called back with an array of manifest objects describing + * each of the patches. + */ + requestAvailablePatchList (callbackFunction) + { + const replyType = this.createReplyID ("patchlist_"); + this.addSingleUseListener (replyType, callbackFunction); + this.sendMessageToServer ({ type: "req_patchlist", + replyType: replyType }); + } + + /** Creates and returns a new PatchConnection object which can be used to control the + * patch that this session has loaded. + */ + createPatchConnection() + { + class ServerPatchConnection extends PatchConnection + { + constructor (session) + { + super(); + this.session = session; + this.manifest = session.status?.manifest; + this.session.activePatchConnections.add (this); + } + + dispose() + { + this.session.activePatchConnections.delete (this); + this.session = undefined; + } + + sendMessageToServer (message) + { + this.session?.sendMessageToServer (message); + } + + getResourceAddress (path) + { + if (! this.session?.status?.httpRootURL) + return undefined; + + return this.session.status.httpRootURL + + (path.startsWith ("/") ? path.substr (1) : path); + } + } + + return new ServerPatchConnection (this); + } + + //============================================================================== + // Audio input source handling: + + /** + * Sets a custom audio input source for a particular endpoint. + * + * When a source is changed, a callback is sent to any audio input mode listeners (see + * `addAudioInputModeListener()`) + * + * @param {Object} endpointID + * @param {boolean} shouldMute - if true, the endpoint will be muted + * @param {Uint8Array | Array} fileDataToPlay - if this is some kind of array containing + * binary data that can be parsed as an audio file, then it will be sent across for the + * server to play as a looped input sample. + */ + setAudioInputSource (endpointID, shouldMute, fileDataToPlay) + { + const loopFile = "_audio_source_" + endpointID; + + if (fileDataToPlay) + { + this.registerFile (loopFile, + { + size: fileDataToPlay.byteLength, + read: (start, length) => { return new Blob ([fileDataToPlay.slice (start, start + length)]); } + }); + + this.sendMessageToServer ({ type: "set_custom_audio_input", + endpoint: endpointID, + file: loopFile }); + } + else + { + this.removeFile (loopFile); + + this.sendMessageToServer ({ type: "set_custom_audio_input", + endpoint: endpointID, + mute: !! shouldMute }); + } + } + + /** Attaches a listener function to be told when the input source for a particular + * endpoint is changed by a call to `setAudioInputSource()`. + */ + addAudioInputModeListener (endpointID, listener) { this.addEventListener ("audio_input_mode_" + endpointID, listener); } + + /** Removes a listener previously added with `addAudioInputModeListener()` */ + removeAudioInputModeListener (endpointID, listener) { this.removeEventListener ("audio_input_mode_" + endpointID, listener); } + + /** Asks the server to send an update with the latest status to any audio mode listeners that + * are attached to the given endpoint. + * @param {string} endpointID + */ + requestAudioInputMode (endpointID) { this.sendMessageToServer ({ type: "req_audio_input_mode", endpoint: endpointID }); } + + //============================================================================== + // Audio device methods: + + /** Enables or disables audio playback. + * When playback state changes, a status update is sent to any status listeners. + * @param {boolean} shouldBeActive + */ + setAudioPlaybackActive (shouldBeActive) { this.sendMessageToServer ({ type: "set_audio_playback_active", active: shouldBeActive }); } + + /** Asks the server to apply a new set of audio device properties. + * The properties object uses the same format as the object that is passed to the listeners + * (see `addAudioDevicePropertiesListener()`). + */ + setAudioDeviceProperties (newProperties) { this.sendMessageToServer ({ type: "set_audio_device_props", properties: newProperties }); } + + /** Attaches a listener function which will be called when the audio device properties are + * changed. + * + * You can remove the listener when it's no longer needed with `removeAudioDevicePropertiesListener()`. + * + * @param listener - this callback will receive an argument object containing all the + * details about the device. + */ + addAudioDevicePropertiesListener (listener) { this.addEventListener ("audio_device_properties", listener); } + + /** Removes a listener that was added with `addAudioDevicePropertiesListener()` */ + removeAudioDevicePropertiesListener (listener) { this.removeEventListener ("audio_device_properties", listener); } + + /** Causes an asynchronous callback to any audio device listeners that are registered. */ + requestAudioDeviceProperties() { this.sendMessageToServer ({ type: "req_audio_device_props" }); } + + //============================================================================== + /** Asks the server to asynchronously generate some code from the currently loaded patch. + * + * @param {string} codeType - this must be one of the strings that are listed in the + * status's `codeGenTargets` property. For example, "cpp" + * would request a C++ version of the patch. + * @param {Object} [extraOptions] - this optionally provides target-specific properties. + * @param callbackFunction - this function will be called with the result when it has + * been generated. Its argument will be an object containing the + * code, errors and other metadata about the patch. + */ + requestGeneratedCode (codeType, extraOptions, callbackFunction) + { + const replyType = this.createReplyID ("codegen_"); + this.addSingleUseListener (replyType, callbackFunction); + this.sendMessageToServer ({ type: "req_codegen", + codeType: codeType, + options: extraOptions, + replyType: replyType }); + } + + //============================================================================== + // File change monitoring: + + /** Attaches a listener to be told when a file change is detected in the currently-loaded + * patch. The function will be called with an object that gives rough details about the + * type of change, i.e. whether it's a manifest or asset file, or a cmajor file, but it + * won't provide any information about exactly which files are involved. + */ + addFileChangeListener (listener) { this.addEventListener ("patch_source_changed", listener); } + + /** Removes a listener that was previously added with `addFileChangeListener()`. + */ + removeFileChangeListener (listener) { this.removeEventListener ("patch_source_changed", listener); } + + //============================================================================== + // CPU level monitoring methods: + + /** Attaches a listener function which will be sent messages containing CPU info. + * To remove the listener, call `removeCPUListener()`. To change the rate of these + * messages, use `setCPULevelUpdateRate()`. + */ + addCPUListener (listener) { this.addEventListener ("cpu_info", listener); this.updateCPULevelUpdateRate(); } + + /** Removes a listener that was previously attached with `addCPUListener()`. */ + removeCPUListener (listener) { this.removeEventListener ("cpu_info", listener); this.updateCPULevelUpdateRate(); } + + /** Changes the frequency at which CPU level update messages are sent to listeners. */ + setCPULevelUpdateRate (framesPerUpdate) { this.cpuFramesPerUpdate = framesPerUpdate; this.updateCPULevelUpdateRate(); } + + /** Attaches a listener to be told when a file change is detected in the currently-loaded + * patch. The function will be called with an object that gives rough details about the + * type of change, i.e. whether it's a manifest or asset file, or a cmajor file, but it + * won't provide any information about exactly which files are involved. + */ + addInfiniteLoopListener (listener) { this.addEventListener ("infinite_loop_detected", listener); } + + /** Removes a listener that was previously added with `addFileChangeListener()`. */ + removeInfiniteLoopListener (listener) { this.removeEventListener ("infinite_loop_detected", listener); } + + //============================================================================== + /** Registers a virtual file with the server, under the given name. + * + * @param {string} filename - the full path name of the file + * @param {Object} contentProvider - this object must have a property called `size` which is a + * constant size in bytes for the file, and a method `read (offset, size)` which + * returns an array (or UInt8Array) of bytes for the data in a given chunk of the file. + * The server may repeatedly call this method at any time until `removeFile()` is + * called to deregister the file. + */ + registerFile (filename, contentProvider) + { + if (! this.files) + this.files = new Map(); + + this.files.set (filename, contentProvider); + + this.sendMessageToServer ({ type: "register_file", + filename: filename, + size: contentProvider.size }); + } + + /** Removes a file that was previously registered with `registerFile()`. */ + removeFile (filename) + { + this.sendMessageToServer ({ type: "remove_file", + filename: filename }); + this.files?.delete (filename); + } + + //============================================================================== + // Private methods from this point... + + /** An implementation subclass must call this when the session first connects + * @private + */ + handleSessionConnection() + { + if (! this.status.connected) + { + this.requestSessionStatus(); + this.requestAudioDeviceProperties(); + + if (this.currentPatchLocation) + { + this.loadPatch (this.currentPatchLocation); + this.currentPatchLocation = undefined; + } + } + } + + /** An implementation subclass must call this when a message arrives + * @private + */ + handleMessageFromServer (msg) + { + this.lastServerMessageTime = Date.now(); + const type = msg.type; + const message = msg.message; + + switch (type) + { + case "cpu_info": + case "audio_device_properties": + case "patch_source_changed": + case "infinite_loop_detected": + this.dispatchEvent (type, message); + break; + + case "session_status": + message.connected = true; + this.setNewStatus (message); + break; + + case "req_file_read": + this.handleFileReadRequest (message); + break; + + case "ping": + this.sendMessageToServer ({ type: "ping" }); + break; + + default: + if (type.startsWith ("audio_input_mode_") || type.startsWith ("reply_")) + { + this.dispatchEvent (type, message); + break; + } + + for (const c of this.activePatchConnections) + c.deliverMessageFromServer (msg); + + break; + } + } + + /** @private */ + checkServerStillExists() + { + if (Date.now() > this.lastServerMessageTime + 10000) + this.setNewStatus ({ + connected: false, + loaded: false, + status: "Cannot connect to the Cmajor server" + }); + } + + /** @private */ + setNewStatus (newStatus) + { + this.status = newStatus; + this.dispatchEvent ("session_status", this.status); + this.updateCPULevelUpdateRate(); + } + + /** @private */ + updateCPULevelUpdateRate() + { + const rate = this.getNumListenersForType ("cpu_info") > 0 ? (this.cpuFramesPerUpdate || 15000) : 0; + this.sendMessageToServer ({ type: "set_cpu_info_rate", + framesPerCallback: rate }); + } + + /** @private */ + handleFileReadRequest (request) + { + const contentProvider = this.files?.get (request?.file); + + if (contentProvider && request.offset !== null && request.size != 0) + { + const data = contentProvider.read (request.offset, request.size); + const reader = new FileReader(); + + reader.onloadend = (e) => + { + const base64 = e.target?.result?.split?.(",", 2)[1]; + + if (base64) + this.sendMessageToServer ({ type: "file_content", + file: request.file, + data: base64, + start: request.offset }); + }; + + reader.readAsDataURL (data); + } + } + + /** @private */ + createReplyID (stem) + { + return "reply_" + stem + this.createRandomID(); + } + + /** @private */ + createRandomID() + { + return (Math.floor (Math.random() * 100000000)).toString(); + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/PirkleFilters/cmaj_api/cmaj_audio_worklet_helper.js new file mode 100644 index 00000000..39f68f9b --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_api/cmaj_audio_worklet_helper.js @@ -0,0 +1,707 @@ + +import { PatchConnection } from "./cmaj-patch-connection.js" + +//============================================================================== +// N.B. code will be serialised to a string, so all `registerWorkletProcessor`s +// dependencies must be self contained and not capture things in the outer scope +async function serialiseWorkletProcessorFactoryToDataURI (WrapperClass, workletName) +{ + const serialisedInvocation = `(${registerWorkletProcessor.toString()}) ("${workletName}", ${WrapperClass.toString()});` + + let reader = new FileReader(); + reader.readAsDataURL (new Blob ([serialisedInvocation], { type: "text/javascript" })); + + return await new Promise (res => { reader.onloadend = () => res (reader.result); }); +} + +function registerWorkletProcessor (workletName, WrapperClass) +{ + function makeConsumeOutputEvents ({ wrapper, eventOutputs, dispatchOutputEvent }) + { + const outputEventHandlers = eventOutputs.map (({ endpointID }) => + { + const readCount = wrapper[`getOutputEventCount_${endpointID}`]?.bind (wrapper); + const reset = wrapper[`resetOutputEventCount_${endpointID}`]?.bind (wrapper); + const readEventAtIndex = wrapper[`getOutputEvent_${endpointID}`]?.bind (wrapper); + + return () => + { + const count = readCount(); + for (let i = 0; i < count; ++i) + dispatchOutputEvent (endpointID, readEventAtIndex (i)); + + reset(); + }; + }); + + return () => outputEventHandlers.forEach ((consume) => consume() ); + } + + function setInitialParameterValues (parametersMap) + { + for (const { initialise } of Object.values (parametersMap)) + initialise(); + } + + function makeEndpointMap (wrapper, endpoints, initialValueOverrides) + { + const toKey = ({ endpointType, endpointID }) => + { + switch (endpointType) + { + case "event": return `sendInputEvent_${endpointID}`; + case "value": return `setInputValue_${endpointID}`; + } + + throw "Unhandled endpoint type"; + }; + + const lookup = {}; + for (const { endpointID, endpointType, annotation, purpose } of endpoints) + { + const key = toKey ({ endpointType, endpointID }); + const wrapperUpdate = wrapper[key]?.bind (wrapper); + + const snapAndConstrainValue = (value) => + { + if (annotation.step != null) + value = Math.round (value / annotation.step) * annotation.step; + + if (annotation.min != null && annotation.max != null) + value = Math.min (Math.max (value, annotation.min), annotation.max); + + return value; + }; + + const update = (value, rampFrames) => + { + // N.B. value clamping and rampFrames from annotations not currently applied + const entry = lookup[endpointID]; + entry.cachedValue = value; + wrapperUpdate (value, rampFrames); + }; + + if (update) + { + const initialValue = initialValueOverrides[endpointID] ?? annotation?.init; + + lookup[endpointID] = { + snapAndConstrainValue, + update, + initialise: initialValue != null ? () => update (initialValue) : () => {}, + purpose, + cachedValue: undefined, + }; + } + } + + return lookup; + } + + function makeStreamEndpointHandler ({ wrapper, toEndpoints, wrapperMethodNamePrefix }) + { + const endpoints = toEndpoints (wrapper); + if (endpoints.length === 0) + return () => {}; + + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; + + return (channels, blockSize) => handleFrames (channels, blockSize); + } + + function makeInputStreamEndpointHandler (wrapper) + { + return makeStreamEndpointHandler ({ + wrapper, + toEndpoints: wrapper => wrapper.getInputEndpoints().filter (({ purpose }) => purpose === "audio in"), + wrapperMethodNamePrefix: "setInputStreamFrames", + }); + } + + function makeOutputStreamEndpointHandler (wrapper) + { + return makeStreamEndpointHandler ({ + wrapper, + toEndpoints: wrapper => wrapper.getOutputEndpoints().filter (({ purpose }) => purpose === "audio out"), + wrapperMethodNamePrefix: "getOutputFrames", + }); + } + + class WorkletProcessor extends AudioWorkletProcessor + { + static get parameterDescriptors() + { + return []; + } + + constructor ({ processorOptions, ...options }) + { + super (options); + + this.processImpl = undefined; + this.consumeOutputEvents = undefined; + + const { sessionID = Date.now() & 0x7fffffff, initialValueOverrides = {} } = processorOptions; + + const wrapper = new WrapperClass(); + + wrapper.initialise (sessionID, sampleRate) + .then (() => this.initialisePatch (wrapper, initialValueOverrides)) + .catch (error => { throw new Error (error)}); + } + + process (inputs, outputs) + { + const input = inputs[0]; + const output = outputs[0]; + + this.processImpl?.(input, output); + this.consumeOutputEvents?.(); + + return true; + } + + sendPatchMessage (payload) + { + this.port.postMessage ({ type: "patch", payload }); + } + + sendParameterValueChanged (endpointID, value) + { + this.sendPatchMessage ({ + type: "param_value", + message: { endpointID, value } + }); + } + + initialisePatch (wrapper, initialValueOverrides) + { + try + { + const inputParameters = wrapper.getInputEndpoints().filter (({ purpose }) => purpose === "parameter"); + const parametersMap = makeEndpointMap (wrapper, inputParameters, initialValueOverrides); + + setInitialParameterValues (parametersMap); + + const toParameterValuesWithKey = (endpointKey, parametersMap) => + { + const toValue = ([endpoint, { cachedValue }]) => ({ [endpointKey]: endpoint, value: cachedValue }); + return Object.entries (parametersMap).map (toValue); + }; + + const initialValues = toParameterValuesWithKey ("endpointID", parametersMap); + const initialState = wrapper.getState(); + + const resetState = () => + { + wrapper.restoreState (initialState); + + // N.B. update cache used for `req_param_value` messages (we don't currently read from the wasm heap) + setInitialParameterValues (parametersMap); + }; + + const isNonAudioOrParameterEndpoint = ({ purpose }) => ! ["audio in", "parameter"].includes (purpose); + const otherInputs = wrapper.getInputEndpoints().filter (isNonAudioOrParameterEndpoint); + const otherInputEndpointsMap = makeEndpointMap (wrapper, otherInputs, initialValueOverrides); + + const isEvent = ({ endpointType }) => endpointType === "event"; + const eventInputs = wrapper.getInputEndpoints().filter (isEvent); + const eventOutputs = wrapper.getOutputEndpoints().filter (isEvent); + + const makeEndpointListenerMap = (eventEndpoints) => + { + const listeners = {}; + + for (const { endpointID } of eventEndpoints) + listeners[endpointID] = []; + + return listeners; + }; + + const inputEventListeners = makeEndpointListenerMap (eventInputs); + const outputEventListeners = makeEndpointListenerMap (eventOutputs); + + this.consumeOutputEvents = makeConsumeOutputEvents ({ + eventOutputs, + wrapper, + dispatchOutputEvent: (endpointID, event) => + { + for (const { replyType } of outputEventListeners[endpointID] ?? []) + { + this.sendPatchMessage ({ + type: replyType, + message: event.event, // N.B. chucking away frame and typeIndex info for now + }); + } + }, + }); + + const blockSize = 128; + const prepareInputFrames = makeInputStreamEndpointHandler (wrapper); + const processOutputFrames = makeOutputStreamEndpointHandler (wrapper); + + this.processImpl = (input, output) => + { + prepareInputFrames (input, blockSize); + wrapper.advance (blockSize); + processOutputFrames (output, blockSize); + }; + + // N.B. the message port makes things straightforward, but it allocates (when sending + receiving). + // so, we aren't doing ourselves any favours. we probably ought to marshal raw bytes over to the gui in + // a pre-allocated lock-free message queue (using `SharedArrayBuffer` + `Atomic`s) and transform the raw + // messages there. + this.port.addEventListener ("message", e => + { + if (e.data.type !== "patch") + return; + + const msg = e.data.payload; + + switch (msg.type) + { + case "req_status": + { + this.sendPatchMessage ({ + type: "status", + message: { + details: { + inputs: wrapper.getInputEndpoints(), + outputs: wrapper.getOutputEndpoints(), + }, + sampleRate, + }, + }); + break; + } + + case "req_reset": + { + resetState(); + initialValues.forEach (v => this.sendParameterValueChanged (v.endpointID, v.value)); + break; + } + + case "req_param_value": + { + // N.B. keep a local cache here so that we can send the values back when requested. + // we could instead have accessors into the wasm heap. + const endpointID = msg.id; + const parameter = parametersMap[endpointID]; + if (! parameter) + return; + + const value = parameter.cachedValue; + this.sendParameterValueChanged (endpointID, value); + break; + } + + case "send_value": + { + const endpointID = msg.id; + const parameter = parametersMap[endpointID]; + + if (parameter) + { + const newValue = parameter.snapAndConstrainValue (msg.value); + parameter.update (newValue, msg.rampFrames); + + this.sendParameterValueChanged (endpointID, newValue); + return; + } + + const inputEndpoint = otherInputEndpointsMap[endpointID]; + + if (inputEndpoint) + { + inputEndpoint.update (msg.value); + + for (const { replyType } of inputEventListeners[endpointID] ?? []) + { + this.sendPatchMessage ({ + type: replyType, + message: inputEndpoint.cachedValue, + }); + } + } + break; + } + + case "send_gesture_start": break; + case "send_gesture_end": break; + + case "req_full_state": + this.sendPatchMessage ({ + type: msg?.replyType, + message: { + parameters: toParameterValuesWithKey ("name", parametersMap), + }, + }); + break; + + case "send_full_state": + { + const { parameters = [] } = e.data.payload?.value || []; + + for (const [endpointID, parameter] of Object.entries (parametersMap)) + { + const namedNextValue = parameters.find (({ name }) => name === endpointID); + + if (namedNextValue) + parameter.update (namedNextValue.value); + else + parameter.initialise(); + + this.sendParameterValueChanged (endpointID, parameter.cachedValue); + } + break; + } + + case "add_endpoint_listener": + { + const insertIfValidEndpoint = (lookup, msg) => + { + const endpointID = msg?.endpoint; + const listeners = lookup[endpointID] + + if (! listeners) + return false; + + return listeners.push ({ replyType: msg?.replyType }) > 0; + }; + + if (! insertIfValidEndpoint (inputEventListeners, msg)) + insertIfValidEndpoint (outputEventListeners, msg) + + break; + } + + case "remove_endpoint_listener": + { + const removeIfValidReplyType = (lookup, msg) => + { + const endpointID = msg?.endpoint; + const listeners = lookup[endpointID]; + + if (! listeners) + return false; + + const index = listeners.indexOf (msg?.replyType); + + if (index === -1) + return false; + + return listeners.splice (index, 1).length === 1; + }; + + if (! removeIfValidReplyType (inputEventListeners, msg)) + removeIfValidReplyType (outputEventListeners, msg) + + break; + } + + default: + break; + } + }); + + this.port.postMessage ({ type: "initialised" }); + this.port.start(); + } + catch (e) + { + this.port.postMessage (e.toString()); + } + } + } + + registerProcessor (workletName, WorkletProcessor); +} + +//============================================================================== +async function connectToAudioIn (audioContext, node) +{ + try + { + const input = await navigator.mediaDevices.getUserMedia ({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }}); + + if (! input) + throw new Error(); + + const source = audioContext.createMediaStreamSource (input); + + if (! source) + throw new Error(); + + source.connect (node); + } + catch (e) + { + console.warn (`Could not open audio input`); + } +} + +async function connectToMIDI (connection, midiEndpointID) +{ + try + { + if (! navigator.requestMIDIAccess) + throw new Error ("Web MIDI API not supported."); + + const midiAccess = await navigator.requestMIDIAccess ({ sysex: true, software: true }); + + for (const input of midiAccess.inputs.values()) + { + input.onmidimessage = ({ data }) => + connection.sendMIDIInputEvent (midiEndpointID, data[2] | (data[1] << 8) | (data[0] << 16)); + } + } + catch (e) + { + console.warn (`Could not open MIDI devices: ${e}`); + } +} + + +//============================================================================== +/** This class provides a PatchConnection that controls a Cmajor audio worklet + * node. + */ +export class AudioWorkletPatchConnection extends PatchConnection +{ + constructor (manifest) + { + super(); + + this.manifest = manifest; + this.cachedState = {}; + } + + //============================================================================== + /** Initialises this connection to load and control the given Cmajor class. + * + * @param {Object} WrapperClass - the generated Cmajor class + * @param {AudioContext} audioContext - a web audio AudioContext object + * @param {string} workletName - the name to give the new worklet that is created + * @param {number} sessionID - an integer to use for the session ID + * @param {Array} patchInputList - a list of the input endpoints that the patch provides + * @param {Object} initialValueOverrides - optional initial values for parameter endpoints + */ + async initialise (WrapperClass, + audioContext, + workletName, + sessionID, + initialValueOverrides) + { + this.audioContext = audioContext; + + const dataURI = await serialiseWorkletProcessorFactoryToDataURI (WrapperClass, workletName); + await audioContext.audioWorklet.addModule (dataURI); + + this.inputEndpoints = WrapperClass.prototype.getInputEndpoints(); + this.outputEndpoints = WrapperClass.prototype.getOutputEndpoints(); + + const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); + const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); + + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; + + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); + + const hasInput = inputChannelCount > 0; + const hasOutput = outputChannelCount > 0; + + const node = new AudioWorkletNode (audioContext, workletName, { + numberOfInputs: +hasInput, + numberOfOutputs: +hasOutput, + channelCountMode: "explicit", + channelCount: hasInput ? inputChannelCount : undefined, + outputChannelCount: hasOutput ? [outputChannelCount] : [], + + processorOptions: + { + sessionID, + initialValueOverrides + } + }); + + const waitUntilWorkletInitialised = async () => + { + return new Promise ((resolve) => + { + const filterForInitialised = (e) => + { + if (e.data.type === "initialised") + { + node.port.removeEventListener ("message", filterForInitialised); + resolve(); + } + }; + + node.port.addEventListener ("message", filterForInitialised); + }); + }; + + node.port.start(); + + await waitUntilWorkletInitialised(); + + this.audioNode = node; + + node.port.addEventListener ("message", e => + { + if (e.data.type === "patch") + { + const msg = e.data.payload; + + if (msg?.type === "status") + msg.message = { manifest: this.manifest, ...msg.message }; + + this.deliverMessageFromServer (msg) + } + }); + + this.startPatchWorker(); + } + + //============================================================================== + /** Attempts to connect this connection to the default audio and MIDI channels. + * This must only be called once initialise() has completed successfully. + * + * @param {AudioContext} audioContext - a web audio AudioContext object + */ + async connectDefaultAudioAndMIDI (audioContext) + { + if (! this.audioNode) + throw new Error ("AudioWorkletPatchConnection.initialise() must have been successfully completed before calling connectDefaultAudioAndMIDI()"); + + const getInputWithPurpose = (purpose) => + { + for (const i of this.inputEndpoints) + if (i.purpose === purpose) + return i.endpointID; + } + + const midiEndpointID = getInputWithPurpose ("midi in"); + + if (midiEndpointID) + connectToMIDI (this, midiEndpointID); + + if (getInputWithPurpose ("audio in")) + connectToAudioIn (audioContext, this.audioNode); + + this.audioNode.connect (audioContext.destination); + } + + //============================================================================== + sendMessageToServer (msg) + { + this.audioNode.port.postMessage ({ type: "patch", payload: msg }); + } + + requestStoredStateValue (key) + { + this.dispatchEvent ("state_key_value", { key, value: this.cachedState[key] }); + } + + sendStoredStateValue (key, newValue) + { + const changed = this.cachedState[key] != newValue; + + if (changed) + { + const shouldRemove = newValue == null; + if (shouldRemove) + { + delete this.cachedState[key]; + return; + } + + this.cachedState[key] = newValue; + // N.B. notifying the client only when updating matches behaviour of the patch player + this.dispatchEvent ("state_key_value", { key, value: newValue }); + } + } + + sendFullStoredState (fullState) + { + const currentStateCleared = (() => + { + const out = {}; + Object.keys (this.cachedState).forEach (k => out[k] = undefined); + return out; + })(); + + const incomingStateValues = fullState.values ?? {}; + const nextStateValues = { ...currentStateCleared, ...incomingStateValues }; + + Object.entries (nextStateValues).forEach (([key, value]) => this.sendStoredStateValue (key, value)); + + // N.B. worklet will handle the `parameters` part + super.sendFullStoredState (fullState); + } + + requestFullStoredState (callback) + { + // N.B. the worklet only handles the `parameters` part, so we patch the key-value state in here + super.requestFullStoredState (msg => callback ({ values: { ...this.cachedState }, ...msg })); + } + + getResourceAddress (path) + { + if (window.location.href.endsWith ("/")) + return window.location.href + path; + + return window.location.href + "/../" + path; + } + + async readResource (path) + { + return fetch (path); + } + + async readResourceAsAudioData (path) + { + const response = await this.readResource (path); + const buffer = await this.audioContext.decodeAudioData (await response.arrayBuffer()); + + let frames = []; + + for (let i = 0; i < buffer.length; ++i) + frames.push ([]); + + for (let chan = 0; chan < buffer.numberOfChannels; ++chan) + { + const src = buffer.getChannelData (chan); + + for (let i = 0; i < buffer.length; ++i) + frames[i].push (src[i]); + } + + return { frames, sampleRate: buffer.sampleRate }; + } + + //============================================================================== + /** @private */ + async startPatchWorker() + { + if (this.manifest.worker?.length > 0) + { + const module = await import (this.getResourceAddress (this.manifest.worker)); + module.default (this); + } + } +} diff --git a/assets/example_patches/PirkleFilters/cmaj_vafilters.js b/assets/example_patches/PirkleFilters/cmaj_vafilters.js new file mode 100644 index 00000000..7aa9f1b3 --- /dev/null +++ b/assets/example_patches/PirkleFilters/cmaj_vafilters.js @@ -0,0 +1,759 @@ +//============================================================================== +// +// This file contains a Javascript/Webassembly/WebAudio export of the Cmajor +// patch 'vafilters.cmajorpatch'. +// +// This file was auto-generated by the Cmajor toolkit v1.0 +// +// To use it, import this module into your HTML/Javascript code and call +// `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection +// object that is returned is a PatchConnection with some extra functionality +// to let you connect it to web audio/MIDI. +// +// For more details about Cmajor, visit https://cmajor.dev +// +//============================================================================== + +import * as helpers from "./cmaj_api/cmaj_audio_worklet_helper.js" + + +//============================================================================== +/** This exports the patch's manifest, in case a caller needs to find out about its properties. + */ +export const manifest = +{ + "CmajorVersion": 1, + "ID": "com.OliLarkin.pirklevafilters", + "version": "1.0", + "name": "vafilters", + "description": "vafilters by will pirkle", + "category": "generator", + "manufacturer": "Oli Larkin", + "isInstrument": true, + "source": "vafilters.cmajor" +}; + +/** Returns the patch's output endpoint list */ +export function getOutputEndpoints() { return FilterTester.prototype.getOutputEndpoints(); } + +/** Returns the patch's input endpoint list */ +export function getInputEndpoints() { return FilterTester.prototype.getInputEndpoints(); } + +//============================================================================== +/** Creates an audio worklet node for the patch with the given name, attaches it + * to the audio context provided, and returns an object containing the node + * and a PatchConnection class to control it. + * + * @param {AudioContext} audioContext - a web audio AudioContext object + * @param {string} workletName - the name to give the new worklet that is created + * @returns {AudioWorkletPatchConnection} an AudioWorkletPatchConnection which has been initialised + */ +export async function createAudioWorkletNodePatchConnection (audioContext, workletName) +{ + const connection = new helpers.AudioWorkletPatchConnection (manifest); + await connection.initialise (FilterTester, audioContext, workletName, Date.now() & 0x7fffffff); + return connection; +} + +/*********************************************************************************** + * + * A Javascript/Webassembly implementation of the Cmajor processor 'FilterTester'. + * + * This class was auto-generated by the Cmajor toolkit. + * + * To use it, construct an instance of this class, and call `initialise()` to + * asynchronously prepare it for use. Once initialised, the class provides + * appropriate setter/getter methods for reading/writing data to its endpoints, + * and an `advance()` method to render the next block. + * + * This roughly mirrors functionality of the cmajor Performer API - see the + * C++ API classes and Cmajor docs for more information about how this is used. + */ +class FilterTester +{ + /** After constructing one of these objects, call its + * initialise() method to prepare it for use. + */ + constructor() + { + } + + //============================================================================== + /** Prepares this processor for use. + * + * @param {number} sessionID - A unique integer ID which will be used for `processor.session`. + * @param {number} frequency - The frequency in Hz that the processor will be expected to run at. + */ + async initialise (sessionID, frequency) + { + if (! ((sessionID ^ 0) > 1)) + throw new Error ("initialise() requires a valid non-zero session ID argument"); + + if (! (frequency > 1)) + throw new Error ("initialise() requires a valid frequency argument"); + + const memory = new WebAssembly.Memory ({ initial: 3 }); + const stack = new WebAssembly.Global ({ value: "i32", mutable: true }, 81856); + + const imports = { + env: { + __linear_memory: memory, + __memory_base: 0, + __stack_pointer: stack, + __table_base: 0, + memcpy: (dst, src, len) => { this.byteMemory.copyWithin (dst, src, src + len); return dst; }, + memmove: (dst, src, len) => { this.byteMemory.copyWithin (dst, src, src + len); return dst; }, + memset: (dst, value, len) => { this.byteMemory.fill (value, dst, dst + len); return dst; } + }, + }; + + const result = await WebAssembly.instantiate (this._getWasmBytes(), imports); + this.instance = result.instance; + const exports = this.instance.exports; + + const memoryBuffer = exports.memory?.buffer || memory.buffer; + this.byteMemory = new Uint8Array (memoryBuffer); + this.memoryDataView = new DataView (memoryBuffer); + + if (exports.advanceBlock) + this._advance = numFrames => exports.advanceBlock (81856, 82160, numFrames); + else + this._advance = () => exports.advanceOneFrame (81856, 82160); + + exports.initialise?.(81856, sessionID, frequency); + return true; + } + + //============================================================================== + /** Advances the processor by a number of frames. + * + * Before calling `advance()` you should use the appropriate functions to + * push data and events into the processor's input endpoints. After calling + * `advance()` you can use its output endpoint access functions to read the + * results. + * + * @param {number} numFrames - An integer number of frames to advance. + * This must be greater than zero. + */ + advance (numFrames) + { + this.byteMemory.fill (0, 82160, 82160 + numFrames * 4); + this._advance (numFrames); + } + + //============================================================================== + /** Returns an object which encapsulates the state of the patch at this point. + * The state can be restored by passing this object to `restoreState()`. + */ + getState() + { + return { memory: this.byteMemory.slice(0) }; + } + + /** Restores the patch to a state that was previously saved by a call to `getState()` + */ + restoreState (savedState) + { + if (savedState?.memory && savedState.memory?.length === this.byteMemory.length) + this.byteMemory.set (savedState.memory); + else + throw Error ("restoreState(): not a valid state object"); + } + + /** Returns a list of the output endpoints that this processor exposes. + * @returns {Array} + */ + getOutputEndpoints() + { + return [ + { + "endpointID": "out", + "endpointType": "stream", + "dataType": { + "type": "float32" + }, + "purpose": "audio out", + "numAudioChannels": 1 + } + ]; + } + + /** Returns a list of the input endpoints that this processor exposes. + * @returns {Array} + */ + getInputEndpoints() + { + return [ + { + "endpointID": "testSignalShape", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Test Signal Shape", + "min": 0, + "max": 4, + "init": 0, + "text": "Sine|Triangle|Square|Ramp Up|Ramp Down" + }, + "purpose": "parameter" + }, + { + "endpointID": "testSignalFrequency", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Test Signal Frequency", + "min": 10, + "max": 200, + "init": 50, + "unit": "Hz" + }, + "purpose": "parameter" + }, + { + "endpointID": "volume", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Test Signal volume", + "unit": "dB", + "min": -85, + "max": 6, + "init": 0 + }, + "purpose": "parameter" + }, + { + "endpointID": "frequencyIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Filter Base Frequency", + "min": 10, + "max": 10000, + "init": 1000, + "unit": "Hz" + }, + "purpose": "parameter" + }, + { + "endpointID": "qualityIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Filter Q", + "min": 1, + "max": 10, + "init": 1, + "step": 0.01 + }, + "purpose": "parameter" + }, + { + "endpointID": "modeIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Filter Mode", + "min": 0, + "max": 1, + "init": 0, + "text": "Lowpass|Highpass" + }, + "purpose": "parameter" + }, + { + "endpointID": "nlIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Filter NonLinearites", + "min": 0, + "max": 1, + "init": 0, + "text": "Off|On" + }, + "purpose": "parameter" + }, + { + "endpointID": "satIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Filter Saturation", + "min": 1, + "max": 10, + "init": 1, + "step": 0.01 + }, + "purpose": "parameter" + }, + { + "endpointID": "rateHzIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "LFO Rate (Hz)", + "min": 0.01, + "max": 10, + "init": 1, + "step": 0.01, + "unit": "Hz" + }, + "purpose": "parameter" + }, + { + "endpointID": "modDepthIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "LFO Depth", + "min": 0.0, + "max": 48, + "init": 0.0, + "unit": "Semi" + }, + "purpose": "parameter" + }, + { + "endpointID": "shapeIn", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "LFO Shape", + "min": 0, + "max": 5, + "init": 0, + "text": "Sine|Triangle|Square|Ramp Up|Ramp Down|Random" + }, + "purpose": "parameter" + } + ]; + } + + /** Sends an event of type `float32` to endpoint "testSignalShape". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_testSignalShape (eventValue) + { + this.instance.exports._sendEvent_testSignalShape (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "testSignalFrequency". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_testSignalFrequency (eventValue) + { + this.instance.exports._sendEvent_testSignalFrequency (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "volume". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_volume (eventValue) + { + this.instance.exports._sendEvent_volume (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "frequencyIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_frequencyIn (eventValue) + { + this.instance.exports._sendEvent_frequencyIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "qualityIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_qualityIn (eventValue) + { + this.instance.exports._sendEvent_qualityIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "modeIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_modeIn (eventValue) + { + this.instance.exports._sendEvent_modeIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "nlIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_nlIn (eventValue) + { + this.instance.exports._sendEvent_nlIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "satIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_satIn (eventValue) + { + this.instance.exports._sendEvent_satIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "rateHzIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_rateHzIn (eventValue) + { + this.instance.exports._sendEvent_rateHzIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "modDepthIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_modDepthIn (eventValue) + { + this.instance.exports._sendEvent_modDepthIn (81856, eventValue); + } + + /** Sends an event of type `float32` to endpoint "shapeIn". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_shapeIn (eventValue) + { + this.instance.exports._sendEvent_shapeIn (81856, eventValue); + } + + /** Returns a frame from the output stream "out" + * + * @param {number} frameIndex - the index of the frame to fetch + */ + getOutputFrame_out (frameIndex) + { + return this.memoryDataView.getFloat32 (82160 + frameIndex * 4, true); + } + + /** Copies frames from the output stream "out" into a destination array. + * + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied + * @param {number} maxNumFramesToRead - The maximum number of frames to copy + */ + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) + { + let source = 82160; + let numDestChans = destChannelArrays.length; + + if (maxNumFramesToRead > 512) + maxNumFramesToRead = 512; + + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; + + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + } + + //============================================================================== + // Code beyond this point is private internal implementation detail + + //============================================================================== + /** @access private */ + _getWasmBytes() + { + return new Uint8Array([0,97,115,109,1,0,0,0,1,191,128,128,128,0,10,96,2,127,125,0,96,3,127,124,124,0,96,2,125,125,1,125,96,2,125,127,1,125,96,3,127,127,124,0,96,3,127,127,127,0,96,1,125,1,125,96,1,127, + 1,125,96,7,127,124,124,124,127,127,127,0,96,3,127,127,127,1,127,2,201,128,128,128,0,4,3,101,110,118,15,95,95,108,105,110,101,97,114,95,109,101,109,111,114,121,2,0,1,3,101,110,118,15,95,95,115,116,97,99, + 107,95,112,111,105,110,116,101,114,3,127,1,3,101,110,118,6,109,101,109,99,112,121,0,9,3,101,110,118,6,109,101,109,115,101,116,0,9,3,149,128,128,128,0,20,0,0,1,0,2,3,0,0,0,0,0,0,0,0,4,5,6,7,8,6,7,155,130, + 128,128,0,13,26,95,115,101,110,100,69,118,101,110,116,95,116,101,115,116,83,105,103,110,97,108,83,104,97,112,101,0,2,30,95,115,101,110,100,69,118,101,110,116,95,116,101,115,116,83,105,103,110,97,108,70, + 114,101,113,117,101,110,99,121,0,3,17,95,115,101,110,100,69,118,101,110,116,95,118,111,108,117,109,101,0,5,22,95,115,101,110,100,69,118,101,110,116,95,102,114,101,113,117,101,110,99,121,73,110,0,8,20,95, + 115,101,110,100,69,118,101,110,116,95,113,117,97,108,105,116,121,73,110,0,9,17,95,115,101,110,100,69,118,101,110,116,95,109,111,100,101,73,110,0,10,15,95,115,101,110,100,69,118,101,110,116,95,110,108,73, + 110,0,11,16,95,115,101,110,100,69,118,101,110,116,95,115,97,116,73,110,0,12,19,95,115,101,110,100,69,118,101,110,116,95,114,97,116,101,72,122,73,110,0,13,21,95,115,101,110,100,69,118,101,110,116,95,109, + 111,100,68,101,112,116,104,73,110,0,14,18,95,115,101,110,100,69,118,101,110,116,95,115,104,97,112,101,73,110,0,15,10,105,110,105,116,105,97,108,105,115,101,0,16,12,97,100,118,97,110,99,101,66,108,111,99, + 107,0,17,12,129,128,128,128,0,2,10,136,242,128,128,0,20,58,1,1,127,2,64,2,64,32,1,142,34,1,139,67,0,0,0,79,93,69,13,0,32,1,168,33,2,12,1,11,65,128,128,128,128,120,33,2,11,32,0,65,36,106,32,2,65,4,32,2, + 65,4,73,27,54,2,0,11,25,0,32,0,65,24,106,65,0,43,3,128,128,128,128,0,32,1,187,16,132,128,128,128,0,11,237,3,4,1,126,1,127,2,126,1,127,2,64,2,64,32,2,32,1,163,34,1,189,34,3,66,52,136,167,65,255,15,113,34, + 4,65,255,15,71,13,0,32,1,32,1,163,33,1,12,1,11,2,64,32,3,66,255,255,255,255,255,255,255,255,255,0,131,34,5,66,128,128,128,128,128,128,128,248,63,86,13,0,32,1,68,0,0,0,0,0,0,0,0,162,32,1,32,5,66,128,128, + 128,128,128,128,128,248,63,81,27,33,1,12,1,11,2,64,2,64,32,4,13,0,65,0,33,4,2,64,32,3,66,12,134,34,5,66,0,83,13,0,3,64,32,4,65,127,106,33,4,32,5,66,1,134,34,5,66,127,85,13,0,11,11,32,3,65,1,32,4,107,173, + 134,33,5,12,1,11,32,3,66,255,255,255,255,255,255,255,7,131,66,128,128,128,128,128,128,128,8,132,33,5,11,2,64,2,64,2,64,32,4,65,128,8,72,13,0,32,4,65,1,106,33,4,3,64,2,64,32,5,66,128,128,128,128,128,128, + 128,120,124,34,6,66,0,83,13,0,32,6,33,5,32,6,80,13,3,11,32,5,66,1,134,33,5,32,4,65,127,106,34,4,65,128,8,74,13,0,11,65,255,7,33,4,11,2,64,2,64,2,64,2,64,32,5,66,128,128,128,128,128,128,128,120,124,34,6, + 66,0,83,13,0,32,6,33,5,32,6,80,13,1,11,32,5,66,255,255,255,255,255,255,255,7,88,13,1,32,5,33,6,12,2,11,32,1,68,0,0,0,0,0,0,0,0,162,33,1,12,4,11,3,64,32,4,65,127,106,33,4,32,5,66,128,128,128,128,128,128, + 128,4,84,33,7,32,5,66,1,134,34,6,33,5,32,7,13,0,11,11,32,3,66,128,128,128,128,128,128,128,128,128,127,131,33,5,2,64,32,4,65,1,72,13,0,32,6,66,128,128,128,128,128,128,128,120,124,32,4,173,66,52,134,132, + 33,6,12,2,11,32,6,65,1,32,4,107,173,135,33,6,12,1,11,32,1,68,0,0,0,0,0,0,0,0,162,33,1,12,1,11,32,6,32,5,132,191,33,1,11,32,0,32,1,182,56,2,4,11,187,1,3,1,127,2,125,2,127,32,0,65,48,106,67,0,0,32,65,32, + 1,67,205,204,76,61,148,16,134,128,128,128,0,67,0,0,0,0,32,1,67,0,0,200,194,94,27,34,1,56,2,0,32,0,65,44,106,34,2,42,2,0,33,3,32,2,32,1,56,2,0,2,64,2,64,67,205,204,204,61,68,0,0,0,0,0,0,240,63,65,0,43,3, + 128,128,128,128,0,163,182,149,34,4,139,67,0,0,0,79,93,69,13,0,32,4,168,33,2,12,1,11,65,128,128,128,128,120,33,2,11,32,0,65,56,106,34,5,40,2,0,33,6,32,5,32,2,65,1,32,2,65,1,74,27,34,2,54,2,0,32,0,65,52, + 106,34,0,32,1,32,3,32,0,42,2,0,32,6,178,148,147,147,32,2,178,149,56,2,0,11,129,13,3,1,125,7,127,4,125,67,0,0,128,63,33,2,2,64,32,1,188,34,3,65,255,255,255,255,7,113,34,4,69,13,0,32,0,188,34,5,65,128,128, + 128,252,3,70,13,0,2,64,2,64,32,5,65,255,255,255,255,7,113,34,6,65,128,128,128,252,7,75,13,0,32,4,65,129,128,128,252,7,73,13,1,11,32,0,32,1,146,15,11,2,64,2,64,32,5,65,127,76,13,0,65,0,33,7,12,1,11,65,2, + 33,7,32,4,65,128,128,128,220,4,79,13,0,2,64,32,4,65,128,128,128,252,3,79,13,0,65,0,33,7,12,1,11,65,0,33,7,32,4,65,150,1,32,4,65,23,118,107,34,8,118,34,9,32,8,116,32,4,71,13,0,65,2,32,9,65,1,113,107,33, + 7,11,2,64,2,64,32,4,65,128,128,128,252,3,70,13,0,32,4,65,128,128,128,252,7,71,13,1,32,6,65,128,128,128,252,3,70,13,2,2,64,32,6,65,129,128,128,252,3,73,13,0,32,1,67,0,0,0,0,32,3,65,127,74,27,15,11,67,0, + 0,0,0,32,1,140,32,3,65,127,74,27,15,11,67,0,0,128,63,32,0,149,32,0,32,3,65,0,72,27,15,11,2,64,2,64,32,3,65,128,128,128,248,3,70,13,0,32,3,65,128,128,128,128,4,71,13,1,32,0,32,0,148,15,11,32,5,65,0,72,13, + 0,32,0,145,15,11,32,0,139,33,2,2,64,2,64,2,64,32,5,65,127,74,13,0,32,5,65,128,128,128,128,120,70,13,1,32,5,65,128,128,128,252,123,70,13,1,32,5,65,128,128,128,124,70,13,1,12,2,11,32,5,69,13,0,32,5,65,128, + 128,128,252,7,70,13,0,32,5,65,128,128,128,252,3,71,13,1,11,67,0,0,128,63,32,2,149,32,2,32,3,65,0,72,27,33,2,32,5,65,0,78,13,1,2,64,32,7,32,6,65,128,128,128,132,124,106,114,13,0,32,2,32,2,147,34,1,32,1, + 149,15,11,32,2,140,32,2,32,7,65,1,70,27,15,11,67,0,0,128,63,33,10,2,64,32,5,65,0,78,13,0,2,64,2,64,32,7,14,2,0,1,2,11,32,0,32,0,147,34,1,32,1,149,15,11,67,0,0,128,191,33,10,11,2,64,2,64,2,64,2,64,2,64, + 2,64,32,4,65,128,128,128,232,4,77,13,0,32,6,65,248,255,255,251,3,79,13,1,32,10,67,202,242,73,113,148,67,202,242,73,113,148,32,10,67,96,66,162,13,148,67,96,66,162,13,148,32,3,65,0,72,27,15,11,32,2,67,0, + 0,128,75,148,188,32,6,32,6,65,128,128,128,4,73,34,7,27,34,6,65,255,255,255,3,113,34,5,65,128,128,128,252,3,114,33,4,65,233,126,65,129,127,32,7,27,32,6,65,23,117,106,33,6,65,0,33,7,32,5,65,242,136,243,0, + 79,13,1,65,1,33,5,12,2,11,2,64,32,6,65,136,128,128,252,3,73,13,0,32,10,67,202,242,73,113,148,67,202,242,73,113,148,32,10,67,96,66,162,13,148,67,96,66,162,13,148,32,3,65,0,74,27,15,11,32,2,67,0,0,128,191, + 146,34,0,67,112,165,236,54,148,32,0,32,0,148,67,0,0,0,63,32,0,32,0,67,0,0,128,190,148,67,171,170,170,62,146,148,147,148,67,59,170,184,191,148,146,34,2,32,2,32,0,67,0,170,184,63,148,34,11,146,188,65,128, + 96,113,190,34,0,32,11,147,147,33,11,12,3,11,2,64,32,5,65,215,231,246,2,79,13,0,67,0,0,192,63,33,0,65,0,33,5,65,128,128,128,1,33,7,12,2,11,32,5,65,128,128,128,248,3,114,33,4,65,1,33,5,32,6,65,1,106,33,6, + 65,0,33,7,11,67,0,0,128,63,33,0,11,67,0,0,0,0,67,220,207,209,53,32,5,27,67,0,0,128,63,32,0,32,4,190,34,12,146,149,34,2,32,12,32,0,147,34,11,32,4,65,1,118,65,128,224,255,255,1,113,32,7,106,65,128,128,128, + 130,2,106,190,34,13,32,11,32,2,148,34,11,188,65,128,96,113,190,34,2,148,147,32,12,32,13,32,0,147,147,32,2,148,147,148,34,0,32,2,32,2,148,34,12,67,0,0,64,64,146,32,0,32,11,32,2,146,148,32,11,32,11,148,34, + 0,32,0,148,32,0,32,0,32,0,32,0,32,0,67,66,241,83,62,148,67,85,50,108,62,146,148,67,5,163,139,62,146,148,67,171,170,170,62,146,148,67,183,109,219,62,146,148,67,154,153,25,63,146,148,146,34,13,146,188,65, + 128,96,113,190,34,0,148,32,11,32,13,32,0,67,0,0,64,192,146,32,12,147,147,148,146,34,11,32,11,32,2,32,0,148,34,2,146,188,65,128,96,113,190,34,0,32,2,147,147,67,79,56,118,63,148,32,0,67,198,35,246,184,148, + 146,146,34,2,67,0,0,0,0,67,0,192,21,63,32,5,27,34,11,32,2,32,0,67,0,64,118,63,148,34,12,146,146,32,6,178,34,2,146,188,65,128,96,113,190,34,0,32,2,147,32,11,147,32,12,147,147,33,11,11,2,64,32,0,32,3,65, + 128,96,113,190,34,2,148,34,12,32,11,32,1,148,32,1,32,2,147,32,0,148,146,34,1,146,34,0,188,34,4,65,128,128,128,152,4,76,13,0,32,10,67,202,242,73,113,148,67,202,242,73,113,148,15,11,2,64,2,64,2,64,32,4,65, + 128,128,128,152,4,71,13,0,65,128,128,128,152,4,33,5,32,1,67,60,170,56,51,146,32,0,32,12,147,94,69,13,1,32,10,67,202,242,73,113,148,67,202,242,73,113,148,15,11,2,64,2,64,32,4,65,255,255,255,255,7,113,34, + 5,65,128,128,216,152,4,75,13,0,32,4,65,128,128,216,152,124,71,13,1,32,1,32,0,32,12,147,95,69,13,1,32,10,67,96,66,162,13,148,67,96,66,162,13,148,15,11,32,10,67,96,66,162,13,148,67,96,66,162,13,148,15,11, + 65,0,33,3,32,5,65,128,128,128,248,3,77,13,1,11,65,0,65,128,128,128,4,32,5,65,23,118,65,130,127,106,118,32,4,106,34,5,65,255,255,255,3,113,65,128,128,128,4,114,65,150,1,32,5,65,23,118,65,255,1,113,34,6, + 107,118,34,3,107,32,3,32,4,65,0,72,27,33,3,32,1,32,12,65,128,128,128,124,32,6,65,129,127,106,117,32,5,113,190,147,34,12,146,188,33,4,11,2,64,32,3,65,23,116,32,4,65,128,128,126,113,190,34,0,67,0,114,49, + 63,148,34,2,32,0,67,140,190,191,53,148,32,1,32,0,32,12,147,147,67,24,114,49,63,148,146,34,11,146,34,1,32,1,32,1,32,1,32,1,148,34,0,32,0,32,0,32,0,32,0,67,76,187,49,51,148,67,14,234,221,181,146,148,67,85, + 179,138,56,146,148,67,97,11,54,187,146,148,67,171,170,42,62,146,148,147,34,0,148,32,0,67,0,0,0,192,146,149,32,11,32,1,32,2,147,147,34,0,32,1,32,0,148,146,147,147,67,0,0,128,63,146,34,1,188,106,34,4,65, + 255,255,255,3,74,13,0,32,10,32,1,32,3,16,135,128,128,128,0,148,15,11,32,10,32,4,190,148,33,2,11,32,2,11,164,1,1,1,127,2,64,2,64,2,64,32,1,65,128,1,72,13,0,32,0,67,0,0,0,127,148,33,0,32,1,65,129,127,106, + 34,2,65,255,0,75,13,1,32,2,33,1,12,2,11,32,1,65,130,127,78,13,1,32,0,67,0,0,128,12,148,33,0,2,64,32,1,65,155,126,77,13,0,32,1,65,230,0,106,33,1,12,2,11,32,0,67,0,0,128,12,148,33,0,32,1,65,182,125,32,1, + 65,182,125,74,27,65,204,1,106,33,1,12,1,11,32,0,67,0,0,0,127,148,33,0,32,1,65,253,2,32,1,65,253,2,72,27,65,130,126,106,33,1,11,32,0,32,1,65,23,116,65,128,128,128,252,3,106,190,148,11,13,0,32,0,65,128,1, + 106,32,1,56,2,0,11,24,0,32,0,65,156,1,106,65,1,58,0,0,32,0,65,144,1,106,32,1,56,2,0,11,56,0,32,0,65,156,1,106,65,1,58,0,0,32,0,65,152,1,106,33,0,2,64,32,1,139,67,0,0,0,79,93,69,13,0,32,0,32,1,168,54,2, + 0,15,11,32,0,65,128,128,128,128,120,54,2,0,11,30,0,32,0,65,156,1,106,65,1,58,0,0,32,0,65,157,1,106,32,1,67,0,0,0,63,94,58,0,0,11,24,0,32,0,65,156,1,106,65,1,58,0,0,32,0,65,148,1,106,32,1,56,2,0,11,34,0, + 32,0,65,232,0,106,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,182,32,1,148,56,2,0,11,13,0,32,0,65,132,1,106,32,1,56,2,0,11,59,1,1,127,2,64,2,64,32,1,142,34,1,139,67,0,0,0,79,93,69,13,0,32,1,168, + 33,2,12,1,11,65,128,128,128,128,120,33,2,11,32,0,65,192,0,106,32,2,65,5,32,2,65,5,73,27,54,2,0,11,149,2,0,65,0,32,2,57,3,128,128,128,128,0,32,0,65,36,106,65,3,54,2,0,32,0,65,24,106,32,2,68,0,0,0,0,0,0, + 89,64,16,132,128,128,128,0,32,0,65,248,0,106,65,1,54,2,0,32,0,65,52,106,66,0,55,2,0,32,0,65,44,106,66,128,128,128,252,131,128,128,192,63,55,2,0,65,0,32,2,57,3,128,128,128,128,0,32,0,65,192,0,106,65,0,54, + 2,0,32,0,65,232,0,106,68,0,0,0,0,0,0,240,63,32,2,163,34,2,182,67,0,0,200,66,148,56,2,0,32,0,65,204,0,106,66,0,55,2,0,32,0,65,196,0,106,66,128,128,128,252,131,128,128,192,63,55,2,0,32,0,65,128,1,106,66, + 128,128,232,163,4,55,2,0,32,0,65,240,0,106,66,201,3,55,3,0,32,0,65,140,1,106,65,128,128,241,176,4,54,2,0,32,0,65,152,1,106,65,0,54,2,0,32,0,65,156,1,106,65,1,59,0,0,32,0,65,144,1,106,66,128,128,128,252, + 131,128,128,192,63,55,2,0,32,0,65,236,0,106,32,2,68,0,0,0,0,0,0,78,64,163,182,67,0,0,240,66,148,56,2,0,11,157,43,11,6,127,3,125,1,127,2,125,1,126,2,124,1,127,1,124,1,127,4,124,1,127,35,128,128,128,128, + 0,65,32,107,34,3,36,128,128,128,128,0,2,64,32,0,40,2,0,32,2,70,13,0,32,0,65,136,2,106,33,4,32,0,65,236,1,106,33,5,32,0,65,208,1,106,33,6,32,0,65,180,1,106,33,7,32,0,65,24,106,33,8,3,64,67,0,0,0,0,33,9, + 2,64,32,0,40,2,40,65,127,70,13,0,67,0,0,0,0,33,9,2,64,2,64,2,64,2,64,2,64,2,64,32,0,40,2,36,14,5,0,1,2,3,4,5,11,32,0,32,0,42,2,24,34,9,32,0,42,2,28,146,34,10,67,0,0,128,191,146,32,10,32,10,67,0,0,128,63, + 96,27,56,2,24,32,9,67,219,15,201,64,148,16,146,128,128,128,0,67,0,0,0,0,146,33,9,12,4,11,32,0,32,8,16,147,128,128,128,0,32,0,42,2,28,34,10,148,32,0,42,2,32,32,10,67,0,0,128,190,148,67,0,0,128,63,146,148, + 146,34,10,56,2,32,32,10,67,0,0,128,64,148,67,0,0,0,0,146,33,9,12,3,11,32,8,16,147,128,128,128,0,67,0,0,0,0,146,33,9,12,2,11,32,0,32,0,42,2,24,34,10,32,0,42,2,28,34,9,146,34,11,67,0,0,128,191,146,32,11, + 32,11,67,0,0,128,63,96,27,56,2,24,2,64,2,64,32,10,32,9,93,69,13,0,32,10,32,9,149,34,9,32,9,146,32,9,32,9,148,147,67,0,0,128,191,146,33,11,12,1,11,67,0,0,0,0,33,11,67,0,0,128,63,32,9,147,32,10,93,69,13, + 0,32,10,67,0,0,128,191,146,32,9,149,34,9,32,9,146,32,9,32,9,148,146,67,0,0,128,63,146,33,11,11,32,10,32,10,146,67,0,0,128,191,146,32,11,147,67,0,0,0,0,146,33,9,12,1,11,32,0,32,0,42,2,24,34,10,32,0,42,2, + 28,34,9,146,34,11,67,0,0,128,191,146,32,11,32,11,67,0,0,128,63,96,27,56,2,24,2,64,2,64,32,10,32,9,93,69,13,0,32,10,32,9,149,34,9,32,9,146,32,9,32,9,148,147,67,0,0,128,191,146,33,11,12,1,11,67,0,0,0,0,33, + 11,67,0,0,128,63,32,9,147,32,10,93,69,13,0,32,10,67,0,0,128,191,146,32,9,149,34,9,32,9,146,32,9,32,9,148,146,67,0,0,128,63,146,33,11,11,32,11,32,10,32,10,146,67,0,0,128,191,146,147,67,0,0,0,0,146,33,9, + 11,32,0,65,1,54,2,40,11,67,0,0,0,0,33,11,2,64,32,0,40,2,60,65,127,70,13,0,2,64,2,64,32,0,40,2,56,34,12,65,1,72,13,0,32,0,32,12,65,127,106,34,12,54,2,56,32,0,42,2,44,32,0,42,2,52,32,12,178,148,147,33,10, + 12,1,11,32,0,42,2,44,33,10,11,32,0,65,1,54,2,60,32,10,67,0,0,0,0,146,33,11,11,67,0,0,0,0,33,10,2,64,32,0,40,2,124,65,127,70,13,0,2,64,2,64,32,0,40,2,80,34,12,65,1,72,13,0,32,0,32,12,65,127,106,34,12,54, + 2,80,32,0,42,2,68,32,0,42,2,76,32,12,178,148,147,33,10,12,1,11,32,0,42,2,68,33,10,11,2,64,2,64,32,0,40,2,64,34,12,65,5,71,13,0,32,10,32,0,42,2,100,148,67,0,0,0,0,146,33,13,12,1,11,32,0,42,2,96,33,13,2, + 64,2,64,2,64,2,64,2,64,32,12,65,127,106,14,4,0,1,2,3,4,11,32,10,67,0,0,128,64,148,32,13,148,33,14,2,64,32,13,67,0,0,0,63,94,69,13,0,32,10,67,0,0,64,64,148,67,0,0,0,0,146,32,14,147,33,13,12,5,11,67,0,0, + 0,0,32,10,147,32,14,146,33,13,12,4,11,32,10,140,32,10,32,13,67,0,0,0,63,94,27,67,0,0,0,0,146,33,13,12,3,11,67,0,0,0,0,32,10,147,32,10,32,10,146,32,13,148,146,33,13,12,2,11,32,10,67,0,0,0,0,146,32,10,32, + 10,146,32,13,148,147,33,13,12,1,11,32,10,32,13,67,219,15,201,64,148,16,146,128,128,128,0,148,67,0,0,0,0,146,33,13,11,32,0,32,0,42,2,104,32,0,42,2,96,146,34,10,56,2,96,2,64,32,10,67,0,0,128,63,96,69,13, + 0,3,64,32,0,32,10,67,0,0,128,191,146,34,10,56,2,96,2,64,32,0,40,2,64,65,5,71,13,0,32,0,32,0,41,3,112,66,237,156,153,142,4,126,66,185,224,0,124,34,15,66,255,255,255,255,7,131,55,3,112,32,0,32,15,167,65, + 255,255,255,255,7,113,178,67,0,0,128,48,148,67,0,0,128,191,146,56,2,100,32,0,42,2,96,33,10,11,32,10,67,0,0,128,63,96,13,0,11,11,32,13,67,0,0,0,0,146,33,10,32,0,65,1,54,2,124,11,2,64,32,0,40,2,136,1,65, + 127,70,13,0,67,0,0,0,64,32,10,32,0,42,2,132,1,148,67,0,0,64,65,149,16,134,128,128,128,0,33,10,32,0,65,1,58,0,156,1,32,0,65,1,54,2,136,1,32,0,32,10,32,0,42,2,128,1,148,56,2,140,1,11,2,64,2,64,32,0,40,2, + 172,2,34,12,65,127,71,13,0,67,0,0,0,0,33,10,12,1,11,32,9,67,0,0,0,0,146,32,11,67,0,0,0,0,146,148,33,10,2,64,32,12,65,0,74,13,0,32,0,66,128,128,128,128,128,128,128,192,63,55,2,192,1,32,0,66,128,128,128, + 252,3,55,2,184,1,32,0,66,128,128,128,252,3,55,2,176,1,32,0,66,138,174,143,225,3,55,2,160,2,32,0,65,128,128,128,252,3,54,2,224,1,32,0,66,0,55,2,216,1,32,0,66,128,128,128,128,128,128,128,192,63,55,2,208, + 1,32,0,66,128,128,128,128,128,128,128,192,63,55,2,200,1,32,0,65,128,128,128,252,3,54,2,252,1,32,0,66,0,55,2,244,1,32,0,66,128,128,128,128,128,128,128,192,63,55,2,236,1,32,0,66,128,128,128,128,128,128,128, + 192,63,55,2,228,1,32,0,66,0,55,2,160,1,32,0,66,128,128,128,128,128,128,128,192,63,55,2,128,2,32,0,66,128,128,128,128,128,128,128,192,63,55,2,136,2,32,0,66,0,55,2,144,2,32,0,65,128,128,128,252,3,54,2,152, + 2,32,0,65,0,54,2,168,1,32,0,65,0,54,2,156,2,32,0,65,0,54,2,172,1,11,32,10,67,0,0,0,0,146,33,9,2,64,2,64,2,64,2,64,2,64,2,64,2,64,3,64,2,64,32,12,65,0,74,13,0,32,0,45,0,156,1,69,13,7,32,0,65,0,58,0,156, + 1,32,0,67,0,0,0,64,32,0,42,2,144,1,34,10,187,68,0,0,0,0,0,0,240,63,165,68,0,0,0,0,0,0,240,191,160,68,215,163,112,61,10,215,255,63,162,68,0,0,0,0,0,0,34,64,163,68,123,20,174,71,225,122,132,63,160,182,32, + 10,67,0,0,32,65,94,27,56,2,160,2,68,0,0,0,0,0,0,0,64,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,34,16,163,33,17,32,0,40,2,152,1,33,18,2,64,32,16,68,139,230,83,94,149,156,251,64,32,0,42,2,140, + 1,34,10,187,68,0,0,0,0,0,0,84,64,165,68,24,45,68,84,251,33,25,64,162,32,10,67,0,160,140,70,94,27,162,68,0,0,0,0,0,0,224,63,162,34,19,189,34,15,66,32,136,167,34,20,65,255,255,255,255,7,113,34,12,65,251, + 195,164,255,3,75,13,0,32,12,65,128,128,128,242,3,73,13,7,65,0,33,20,2,64,32,15,66,128,128,128,128,128,255,255,255,255,0,131,66,129,128,128,128,240,132,229,242,63,84,34,12,13,0,68,24,45,68,84,251,33,233, + 63,32,19,154,32,19,32,15,66,0,83,27,161,68,7,92,20,51,38,166,129,60,160,33,19,32,15,66,63,136,167,33,20,11,32,19,32,19,32,19,32,19,162,34,21,162,34,22,68,99,85,85,85,85,85,213,63,162,32,21,32,22,32,21, + 32,21,162,34,23,32,23,32,23,32,23,32,23,68,115,83,96,219,203,117,243,190,162,68,166,146,55,160,136,126,20,63,160,162,68,1,101,242,242,216,68,67,63,160,162,68,40,3,86,201,34,109,109,63,160,162,68,55,214, + 6,132,244,100,150,63,160,162,68,122,254,16,17,17,17,193,63,160,32,21,32,23,32,23,32,23,32,23,32,23,68,212,122,191,116,112,42,251,62,162,68,233,167,240,50,15,184,18,63,160,162,68,104,16,141,26,247,38,48, + 63,160,162,68,21,131,224,254,200,219,87,63,160,162,68,147,132,110,233,227,38,130,63,160,162,68,254,65,179,27,186,161,171,63,160,162,160,162,68,0,0,0,0,0,0,0,0,160,162,68,0,0,0,0,0,0,0,0,160,160,34,21,160, + 33,23,2,64,32,12,69,13,0,32,23,33,19,12,8,11,68,0,0,0,0,0,0,240,63,32,19,32,21,32,23,32,23,162,32,23,68,0,0,0,0,0,0,240,63,160,163,161,160,34,19,32,19,160,161,34,19,154,32,19,32,20,27,33,19,12,7,11,32, + 12,65,255,255,191,255,7,75,13,5,2,64,32,12,65,250,212,189,128,4,75,13,0,2,64,32,20,65,255,255,63,113,65,251,195,36,71,13,0,2,64,32,12,65,20,118,34,12,32,19,32,19,68,131,200,201,109,48,95,228,63,162,68, + 0,0,0,0,0,0,56,67,160,68,0,0,0,0,0,0,56,195,160,34,21,68,0,0,64,84,251,33,249,191,162,160,34,19,32,21,68,49,99,98,26,97,180,208,61,162,34,22,161,34,23,189,66,52,136,167,65,255,15,113,107,65,17,72,13,0, + 2,64,32,12,32,19,32,21,68,0,0,96,26,97,180,208,61,162,34,23,161,34,24,32,21,68,115,112,3,46,138,25,163,59,162,32,19,32,24,161,32,23,161,161,34,22,161,34,23,189,66,52,136,167,65,255,15,113,107,65,50,78, + 13,0,32,24,33,19,12,1,11,32,24,32,21,68,0,0,0,46,138,25,163,59,162,34,23,161,34,19,32,21,68,193,73,32,37,154,131,123,57,162,32,24,32,19,161,32,23,161,161,34,22,161,33,23,11,32,19,32,23,161,32,22,161,33, + 22,2,64,32,21,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,21,170,33,12,12,7,11,65,128,128,128,128,120,33,12,12,6,11,2,64,32,12,65,252,178,139,128,4,75,13,0,2,64,32,15,66,127,87,13,0,32,19,68,0,0,64,84,251, + 33,249,191,160,34,19,32,19,68,49,99,98,26,97,180,208,189,160,34,23,161,68,49,99,98,26,97,180,208,189,160,33,22,65,1,33,12,12,7,11,32,19,68,0,0,64,84,251,33,249,63,160,34,19,32,19,68,49,99,98,26,97,180, + 208,61,160,34,23,161,68,49,99,98,26,97,180,208,61,160,33,22,65,127,33,12,12,6,11,32,15,66,127,85,13,4,32,19,68,0,0,64,84,251,33,9,64,160,34,19,32,19,68,49,99,98,26,97,180,224,61,160,34,23,161,68,49,99, + 98,26,97,180,224,61,160,33,22,65,126,33,12,12,5,11,2,64,2,64,2,64,2,64,32,12,65,188,140,241,128,4,73,13,0,32,12,65,251,195,228,137,4,79,13,3,32,12,65,20,118,34,12,32,19,32,19,68,131,200,201,109,48,95,228, + 63,162,68,0,0,0,0,0,0,56,67,160,68,0,0,0,0,0,0,56,195,160,34,21,68,0,0,64,84,251,33,249,191,162,160,34,19,32,21,68,49,99,98,26,97,180,208,61,162,34,22,161,34,23,189,66,52,136,167,65,255,15,113,107,65,17, + 72,13,2,32,12,32,19,32,21,68,0,0,96,26,97,180,208,61,162,34,23,161,34,24,32,21,68,115,112,3,46,138,25,163,59,162,32,19,32,24,161,32,23,161,161,34,22,161,34,23,189,66,52,136,167,65,255,15,113,107,65,50, + 78,13,1,32,24,33,19,12,2,11,2,64,2,64,2,64,2,64,32,12,65,189,251,215,128,4,73,13,0,32,12,65,251,195,228,128,4,71,13,3,32,19,32,19,68,131,200,201,109,48,95,228,63,162,68,0,0,0,0,0,0,56,67,160,68,0,0,0,0, + 0,0,56,195,160,34,21,68,0,0,64,84,251,33,249,191,162,160,34,19,32,21,68,49,99,98,26,97,180,208,61,162,34,22,161,34,23,189,66,128,128,128,128,128,128,128,248,255,0,131,66,255,255,255,255,255,255,255,135, + 63,86,13,2,32,19,32,21,68,0,0,96,26,97,180,208,61,162,34,23,161,34,24,32,21,68,115,112,3,46,138,25,163,59,162,32,19,32,24,161,32,23,161,161,34,22,161,34,23,189,66,128,128,128,128,128,128,128,128,255,0, + 131,66,255,255,255,255,255,255,255,255,60,88,13,1,32,24,33,19,12,2,11,32,12,65,252,178,203,128,4,70,13,8,2,64,32,15,66,0,83,13,0,32,19,68,0,0,48,127,124,217,18,192,160,34,19,32,19,68,202,148,147,167,145, + 14,233,189,160,34,23,161,68,202,148,147,167,145,14,233,189,160,33,22,65,3,33,12,12,11,11,32,19,68,0,0,48,127,124,217,18,64,160,34,19,32,19,68,202,148,147,167,145,14,233,61,160,34,23,161,68,202,148,147, + 167,145,14,233,61,160,33,22,65,125,33,12,12,10,11,32,24,32,21,68,0,0,0,46,138,25,163,59,162,34,23,161,34,19,32,21,68,193,73,32,37,154,131,123,57,162,32,24,32,19,161,32,23,161,161,34,22,161,33,23,11,32, + 19,32,23,161,32,22,161,33,22,2,64,32,21,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,21,170,33,12,12,9,11,65,128,128,128,128,120,33,12,12,8,11,2,64,32,15,66,0,83,13,0,32,19,68,0,0,64,84,251,33,25,192,160,34, + 19,32,19,68,49,99,98,26,97,180,240,189,160,34,23,161,68,49,99,98,26,97,180,240,189,160,33,22,65,4,33,12,12,8,11,32,19,68,0,0,64,84,251,33,25,64,160,34,19,32,19,68,49,99,98,26,97,180,240,61,160,34,23,161, + 68,49,99,98,26,97,180,240,61,160,33,22,65,124,33,12,12,7,11,32,24,32,21,68,0,0,0,46,138,25,163,59,162,34,23,161,34,19,32,21,68,193,73,32,37,154,131,123,57,162,32,24,32,19,161,32,23,161,161,34,22,161,33, + 23,11,32,19,32,23,161,32,22,161,33,22,2,64,32,21,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,21,170,33,12,12,6,11,65,128,128,128,128,120,33,12,12,5,11,2,64,2,64,32,15,66,255,255,255,255,255,255,255,7,131,66, + 128,128,128,128,128,128,128,176,193,0,132,191,34,19,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,19,170,33,20,12,1,11,65,128,128,128,128,120,33,20,11,2,64,2,64,32,19,32,20,183,34,23,161,68,0,0,0,0,0,0,112,65, + 162,34,19,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,19,170,33,25,12,1,11,65,128,128,128,128,120,33,25,11,32,3,65,8,106,32,23,32,25,183,34,21,32,19,32,21,161,68,0,0,0,0,0,0,112,65,162,34,19,32,12,65,20,118, + 65,234,119,106,65,3,65,2,32,20,65,0,71,32,25,27,32,19,68,0,0,0,0,0,0,0,0,100,32,19,68,0,0,0,0,0,0,0,0,99,114,27,65,1,16,148,128,128,128,0,32,3,43,3,24,33,22,32,3,43,3,16,33,23,32,3,40,2,8,33,12,32,15,66, + 127,85,13,4,65,0,32,12,107,33,12,32,22,154,33,22,32,23,154,33,23,12,4,11,32,0,32,0,40,2,168,2,65,127,106,34,18,54,2,168,2,65,0,33,12,32,18,65,0,72,13,0,12,7,11,11,2,64,32,19,32,19,68,131,200,201,109,48, + 95,228,63,162,68,0,0,0,0,0,0,56,67,160,68,0,0,0,0,0,0,56,195,160,34,21,68,0,0,64,84,251,33,249,191,162,160,34,19,32,21,68,49,99,98,26,97,180,208,61,162,34,22,161,34,23,189,66,128,128,128,128,128,128,128, + 248,255,0,131,66,255,255,255,255,255,255,255,135,63,86,13,0,2,64,32,19,32,21,68,0,0,96,26,97,180,208,61,162,34,23,161,34,24,32,21,68,115,112,3,46,138,25,163,59,162,32,19,32,24,161,32,23,161,161,34,22,161, + 34,23,189,66,128,128,128,128,128,128,128,128,255,0,131,66,255,255,255,255,255,255,255,255,60,88,13,0,32,24,33,19,12,1,11,32,24,32,21,68,0,0,0,46,138,25,163,59,162,34,23,161,34,19,32,21,68,193,73,32,37, + 154,131,123,57,162,32,24,32,19,161,32,23,161,161,34,22,161,33,23,11,32,19,32,23,161,32,22,161,33,22,2,64,32,21,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,21,170,33,12,12,2,11,65,128,128,128,128,120,33,12, + 12,1,11,32,19,68,0,0,64,84,251,33,9,192,160,34,19,32,19,68,49,99,98,26,97,180,224,189,160,34,23,161,68,49,99,98,26,97,180,224,189,160,33,22,65,2,33,12,11,65,0,33,25,2,64,32,23,189,34,15,66,128,128,128, + 128,128,255,255,255,255,0,131,66,129,128,128,128,240,132,229,242,63,84,34,20,13,0,68,24,45,68,84,251,33,233,63,32,23,154,32,23,32,15,66,0,83,34,25,27,161,68,7,92,20,51,38,166,129,60,32,22,154,32,22,32, + 25,27,161,160,33,23,32,15,66,63,136,167,33,25,68,0,0,0,0,0,0,0,0,33,22,11,32,12,65,1,113,33,12,32,23,32,23,32,23,32,23,162,34,21,162,34,24,68,99,85,85,85,85,85,213,63,162,32,22,32,21,32,22,32,24,32,21, + 32,21,162,34,19,32,19,32,19,32,19,32,19,68,115,83,96,219,203,117,243,190,162,68,166,146,55,160,136,126,20,63,160,162,68,1,101,242,242,216,68,67,63,160,162,68,40,3,86,201,34,109,109,63,160,162,68,55,214, + 6,132,244,100,150,63,160,162,68,122,254,16,17,17,17,193,63,160,32,21,32,19,32,19,32,19,32,19,32,19,68,212,122,191,116,112,42,251,62,162,68,233,167,240,50,15,184,18,63,160,162,68,104,16,141,26,247,38,48, + 63,160,162,68,21,131,224,254,200,219,87,63,160,162,68,147,132,110,233,227,38,130,63,160,162,68,254,65,179,27,186,161,171,63,160,162,160,162,160,162,160,160,34,21,160,33,19,2,64,32,20,13,0,65,1,32,12,65, + 1,116,107,183,34,22,32,23,32,21,32,19,32,19,162,32,19,32,22,160,163,161,160,34,19,32,19,160,161,34,19,154,32,19,32,25,27,33,19,12,2,11,32,12,69,13,1,68,0,0,0,0,0,0,240,191,32,19,163,34,22,32,19,189,66, + 128,128,128,128,112,131,191,34,24,32,22,189,66,128,128,128,128,112,131,191,34,19,162,68,0,0,0,0,0,0,240,63,160,32,21,32,24,32,23,161,161,32,19,162,160,162,32,19,160,33,19,12,1,11,32,19,32,19,161,33,19, + 11,32,0,32,16,32,17,32,19,162,162,68,0,0,0,0,0,0,224,63,162,182,187,34,19,32,19,68,0,0,0,0,0,0,240,63,160,34,19,163,182,34,10,56,2,132,2,32,0,32,10,56,2,232,1,32,0,32,10,56,2,204,1,32,0,32,10,56,2,176, + 1,32,0,68,0,0,0,0,0,0,240,63,68,0,0,0,0,0,0,240,63,32,0,42,2,160,2,32,10,148,34,11,187,161,32,11,32,10,148,187,160,163,182,56,2,164,2,2,64,2,64,32,18,13,0,32,0,42,2,160,2,34,11,32,11,32,10,148,147,187, + 33,16,68,0,0,0,0,0,0,240,191,33,23,32,6,33,12,32,5,33,18,12,1,11,32,10,187,154,33,16,68,0,0,0,0,0,0,240,63,33,23,32,4,33,12,32,7,33,18,11,32,18,32,23,32,19,163,182,56,2,0,32,12,32,16,32,19,163,182,56,2, + 0,11,32,0,65,15,54,2,168,2,11,32,0,42,2,148,1,33,11,32,0,45,0,157,1,33,12,2,64,2,64,32,0,40,2,152,1,13,0,32,0,32,0,42,2,176,1,32,0,42,2,196,1,32,9,32,0,42,2,184,1,148,32,0,42,2,200,1,34,9,146,32,0,42,2, + 192,1,32,0,42,2,180,1,32,0,42,2,160,1,34,10,32,9,32,0,42,2,188,1,148,146,148,148,146,148,32,10,147,148,34,9,32,10,32,9,146,34,10,146,56,2,160,1,32,0,42,2,164,2,32,10,32,0,42,2,236,1,32,0,42,2,168,1,32, + 0,42,2,128,2,32,0,42,2,244,1,148,146,148,32,0,42,2,208,1,32,0,42,2,164,1,32,0,42,2,228,1,32,0,42,2,216,1,148,146,148,146,146,148,33,10,2,64,32,12,65,1,113,69,13,0,32,11,32,10,148,16,149,128,128,128,0,33, + 10,11,32,0,32,0,42,2,204,1,32,0,42,2,224,1,32,10,32,0,42,2,212,1,148,32,0,42,2,228,1,34,9,146,32,0,42,2,220,1,32,0,42,2,208,1,32,0,42,2,164,1,34,10,32,9,32,0,42,2,216,1,148,146,148,148,146,148,32,10,147, + 148,34,9,32,10,32,9,146,34,10,146,56,2,164,1,32,0,32,0,42,2,232,1,32,0,42,2,252,1,32,0,42,2,128,2,34,11,32,0,42,2,240,1,32,0,42,2,160,2,32,10,148,34,10,148,146,32,0,42,2,248,1,32,0,42,2,236,1,32,0,42,2, + 168,1,34,9,32,11,32,0,42,2,244,1,148,146,148,148,146,148,32,9,147,148,34,11,32,9,32,11,146,146,56,2,168,1,12,1,11,32,0,32,0,42,2,232,1,32,0,42,2,252,1,32,9,32,0,42,2,240,1,148,32,0,42,2,128,2,34,9,146, + 32,0,42,2,248,1,32,0,42,2,236,1,32,0,42,2,168,1,34,10,32,9,32,0,42,2,244,1,148,146,148,148,146,34,9,148,32,10,147,148,34,13,32,10,32,13,146,34,10,146,56,2,168,1,32,0,42,2,160,2,32,9,32,10,147,32,0,42,2, + 164,2,148,32,0,42,2,136,2,32,0,42,2,172,1,32,0,42,2,156,2,32,0,42,2,144,2,148,146,148,32,0,42,2,180,1,32,0,42,2,160,1,32,0,42,2,200,1,32,0,42,2,188,1,148,146,148,146,146,148,33,10,2,64,32,12,65,1,113,69, + 13,0,32,11,32,10,148,16,149,128,128,128,0,33,10,11,32,0,32,0,42,2,132,2,32,0,42,2,152,2,32,10,32,0,42,2,140,2,148,32,0,42,2,156,2,34,11,146,32,0,42,2,148,2,32,0,42,2,136,2,32,0,42,2,172,1,34,9,32,11,32, + 0,42,2,144,2,148,146,148,148,146,34,11,148,32,9,147,148,34,13,32,9,32,13,146,34,9,146,56,2,172,1,32,0,32,0,42,2,176,1,32,0,42,2,196,1,32,0,42,2,200,1,34,13,32,0,42,2,184,1,32,11,32,9,147,148,146,32,0,42, + 2,192,1,32,0,42,2,180,1,32,0,42,2,160,1,34,9,32,13,32,0,42,2,188,1,148,146,148,148,146,148,32,9,147,148,34,11,32,9,32,11,146,146,56,2,160,1,11,2,64,32,0,42,2,160,2,34,9,67,0,0,0,0,94,69,13,0,32,10,67,0, + 0,128,63,32,9,149,148,33,10,11,32,0,65,1,54,2,172,2,32,10,67,0,0,0,0,146,33,10,11,32,1,32,0,40,2,0,65,2,116,106,32,10,67,0,0,0,0,146,56,2,0,32,0,32,0,40,2,0,65,1,106,34,12,54,2,0,32,12,32,2,71,13,0,11, + 11,32,0,65,0,54,2,0,32,3,65,32,106,36,128,128,128,128,0,11,205,10,3,3,127,3,124,1,127,35,128,128,128,128,0,65,32,107,34,1,36,128,128,128,128,0,2,64,2,64,32,0,188,34,2,65,255,255,255,255,7,113,34,3,65,218, + 159,164,250,3,75,13,0,32,0,32,0,187,34,4,32,4,162,34,5,32,4,162,34,6,32,5,32,5,162,162,32,5,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,6,32,5,68,178,251,110,137,16,17, + 129,63,162,68,119,172,203,84,85,85,197,191,160,162,32,4,160,160,182,32,3,65,128,128,128,204,3,73,27,33,0,12,1,11,2,64,2,64,2,64,2,64,32,3,65,210,167,237,131,4,73,13,0,32,3,65,214,227,136,135,4,79,13,2, + 32,0,187,33,5,32,3,65,224,219,191,133,4,79,13,1,68,0,0,0,0,0,0,240,63,32,5,68,210,33,51,127,124,217,18,64,160,34,4,32,4,162,34,4,68,129,94,12,253,255,255,223,63,162,161,32,4,32,4,162,34,6,68,66,58,5,225, + 83,85,165,63,162,160,32,4,32,6,162,32,4,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,68,0,0,0,0,0,0,240,63,32,5,68,210,33,51,127,124,217,18,192,160,34,5,32,5,162,34, + 5,68,129,94,12,253,255,255,223,63,162,161,32,5,32,5,162,34,4,68,66,58,5,225,83,85,165,63,162,160,32,5,32,4,162,32,5,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140, + 32,2,65,0,72,27,33,0,12,4,11,32,0,187,33,5,32,3,65,228,151,219,128,4,73,13,2,68,24,45,68,84,251,33,9,64,68,24,45,68,84,251,33,9,192,32,2,65,0,72,27,32,5,160,34,4,32,4,162,34,5,32,4,154,162,34,6,32,5,32, + 5,162,162,32,5,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,6,32,5,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,32,4,161,160,182,33,0,12, + 3,11,68,24,45,68,84,251,33,25,64,68,24,45,68,84,251,33,25,192,32,2,65,0,72,27,32,5,160,34,4,32,4,32,4,162,34,5,162,34,6,32,5,32,5,162,162,32,5,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249, + 0,42,191,160,162,32,4,32,6,32,5,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,182,33,0,12,2,11,2,64,32,3,65,255,255,255,251,7,75,13,0,2,64,2,64,32,3,65,218,159,164, + 238,4,75,13,0,32,0,187,34,5,32,5,68,131,200,201,109,48,95,228,63,162,68,0,0,0,0,0,0,56,67,160,68,0,0,0,0,0,0,56,195,160,34,4,68,0,0,0,80,251,33,249,191,162,160,32,4,68,99,98,26,97,180,16,81,190,162,160, + 33,5,2,64,32,4,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,4,170,33,3,12,2,11,65,128,128,128,128,120,33,3,12,1,11,32,1,65,8,106,32,3,32,3,65,23,118,65,234,126,106,34,7,65,23,116,107,190,187,68,0,0,0,0,0,0, + 0,0,68,0,0,0,0,0,0,0,0,32,7,65,1,65,0,16,148,128,128,128,0,32,1,43,3,16,33,5,32,1,40,2,8,33,3,32,2,65,127,74,13,0,65,0,32,3,107,33,3,32,5,154,33,5,11,2,64,2,64,2,64,2,64,32,3,65,3,113,14,3,0,1,2,3,11,32, + 5,32,5,32,5,162,34,4,162,34,6,32,4,32,4,162,162,32,4,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,5,32,6,32,4,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85, + 85,197,191,160,162,160,160,182,33,0,12,5,11,32,5,32,5,162,34,5,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,5,32,5,162,34,4,68,66,58,5,225,83,85,165,63,162,160,32,5,32,4,162,32,5,68, + 105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,33,0,12,4,11,32,5,32,5,162,34,4,32,5,154,162,34,6,32,4,32,4,162,162,32,4,68,167,70,59,140,135,205,198,62,162,68,116,231,202, + 226,249,0,42,191,160,162,32,6,32,4,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,32,5,161,160,182,33,0,12,3,11,32,5,32,5,162,34,5,68,129,94,12,253,255,255,223,191,162,68,0, + 0,0,0,0,0,240,63,160,32,5,32,5,162,34,4,68,66,58,5,225,83,85,165,63,162,160,32,5,32,4,162,32,5,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140,33,0,12,2,11,32,0,32, + 0,147,33,0,12,1,11,68,0,0,0,0,0,0,240,63,32,5,68,24,45,68,84,251,33,249,63,160,34,4,32,4,162,34,4,68,129,94,12,253,255,255,223,63,162,161,32,4,32,4,162,34,6,68,66,58,5,225,83,85,165,63,162,160,32,4,32, + 6,162,32,4,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140,68,0,0,0,0,0,0,240,63,32,5,68,24,45,68,84,251,33,249,191,160,34,5,32,5,162,34,5,68,129,94,12,253,255,255, + 223,63,162,161,32,5,32,5,162,34,4,68,66,58,5,225,83,85,165,63,162,160,32,5,32,4,162,32,5,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,32,2,65,0,72,27,33,0,11,32,1, + 65,32,106,36,128,128,128,128,0,32,0,11,162,5,3,4,125,4,127,1,125,32,0,32,0,42,2,0,34,1,32,0,42,2,4,34,2,146,34,3,67,0,0,128,191,146,32,3,32,3,67,0,0,128,63,96,27,56,2,0,2,64,2,64,32,1,32,2,93,69,13,0,32, + 1,32,2,149,34,3,32,3,146,32,3,32,3,148,147,67,0,0,128,191,146,33,4,12,1,11,67,0,0,0,0,33,4,67,0,0,128,63,32,2,147,32,1,93,69,13,0,32,1,67,0,0,128,191,146,32,2,149,34,3,32,3,146,32,3,32,3,148,146,67,0,0, + 128,63,146,33,4,11,2,64,2,64,32,1,67,0,0,0,63,146,34,3,188,34,5,65,23,118,65,255,1,113,34,6,65,255,1,71,13,0,32,3,32,3,149,33,3,12,1,11,2,64,32,5,65,255,255,255,255,7,113,34,0,65,128,128,128,252,3,75,13, + 0,32,3,67,0,0,0,0,148,32,3,32,0,65,128,128,128,252,3,70,27,33,3,12,1,11,2,64,2,64,32,6,13,0,65,0,33,6,2,64,32,5,65,9,116,34,0,65,0,72,13,0,65,0,33,6,3,64,32,6,65,127,106,33,6,32,0,65,1,116,34,0,65,127, + 74,13,0,11,11,32,5,65,1,32,6,107,116,33,0,12,1,11,32,5,65,255,255,255,3,113,65,128,128,128,4,114,33,0,11,2,64,2,64,2,64,32,6,65,128,1,72,13,0,32,6,65,1,106,33,7,3,64,2,64,32,0,65,128,128,128,124,106,34, + 6,65,0,72,13,0,32,6,33,0,32,6,69,13,3,11,32,0,65,1,116,33,0,32,7,65,127,106,34,7,65,128,1,74,13,0,11,65,255,0,33,6,11,2,64,2,64,2,64,2,64,32,0,65,128,128,128,124,106,34,7,65,0,72,13,0,32,7,33,0,32,7,69, + 13,1,11,32,0,65,255,255,255,3,77,13,1,32,0,33,8,12,2,11,32,3,67,0,0,0,0,148,33,3,12,4,11,3,64,32,6,65,127,106,33,6,32,0,65,128,128,128,2,73,33,7,32,0,65,1,116,34,8,33,0,32,7,13,0,11,11,32,5,65,128,128, + 128,128,120,113,33,0,2,64,32,6,65,1,72,13,0,32,8,65,128,128,128,124,106,32,6,65,23,116,114,33,6,12,2,11,32,8,65,1,32,6,107,117,33,6,12,1,11,32,3,67,0,0,0,0,148,33,3,12,1,11,32,6,32,0,114,190,33,3,11,2, + 64,2,64,32,3,32,2,93,69,13,0,32,3,32,2,149,34,2,32,2,146,32,2,32,2,148,147,67,0,0,128,191,146,33,9,12,1,11,67,0,0,0,0,33,9,67,0,0,128,63,32,2,147,32,3,93,69,13,0,32,3,67,0,0,128,191,146,32,2,149,34,2,32, + 2,146,32,2,32,2,148,146,67,0,0,128,63,146,33,9,11,67,0,0,128,191,67,0,0,128,63,32,1,67,0,0,0,63,93,27,32,4,147,32,9,146,11,240,25,7,10,127,1,124,3,127,1,124,10,127,1,124,3,127,35,128,128,128,128,0,65,192, + 6,107,34,7,36,128,128,128,128,0,32,7,65,184,4,106,65,144,128,128,128,0,65,136,2,16,128,128,128,128,0,26,32,7,66,128,128,128,128,208,227,252,180,53,55,3,176,4,32,7,66,128,128,128,128,168,196,224,241,54, + 55,3,168,4,32,7,66,128,128,128,128,132,164,137,189,56,55,3,160,4,32,7,66,128,128,128,128,184,240,134,248,57,55,3,152,4,32,7,66,128,128,128,128,150,138,179,188,59,55,3,144,4,32,7,66,128,128,128,128,136, + 211,145,252,60,55,3,136,4,32,7,66,128,128,128,128,208,133,145,186,62,55,3,128,4,32,7,66,128,128,128,128,180,191,200,252,63,55,3,248,3,65,0,33,8,32,7,65,248,2,106,65,0,65,128,1,16,129,128,128,128,0,26,32, + 7,65,248,1,106,65,0,65,128,1,16,129,128,128,128,0,26,32,7,65,248,0,106,65,0,65,128,1,16,129,128,128,128,0,26,32,4,65,125,106,65,24,109,34,9,65,0,32,9,65,0,74,27,34,10,65,104,108,32,4,106,33,11,2,64,32, + 6,65,3,106,34,12,32,5,65,127,106,34,13,106,34,14,65,0,72,13,0,32,10,65,2,116,32,5,65,2,116,107,32,7,65,184,4,106,106,65,4,106,33,9,32,10,32,13,107,34,15,33,4,3,64,32,4,65,194,0,110,33,16,2,64,2,64,32,15, + 32,8,106,65,127,74,13,0,68,0,0,0,0,0,0,0,0,33,17,12,1,11,32,9,32,16,65,248,125,108,106,40,2,0,183,33,17,11,32,7,65,248,2,106,32,8,65,15,113,65,3,116,106,32,17,57,3,0,32,9,65,4,106,33,9,32,4,65,1,106,33, + 4,32,8,65,1,106,34,8,32,14,76,13,0,11,11,32,11,65,104,106,33,18,2,64,32,12,65,127,76,13,0,65,0,33,8,2,64,32,5,65,0,74,13,0,3,64,32,7,65,248,0,106,32,8,65,15,113,65,3,116,106,66,0,55,3,0,32,8,65,1,106,34, + 8,32,12,76,13,0,12,2,11,11,65,0,33,16,3,64,68,0,0,0,0,0,0,0,0,33,17,32,7,65,224,0,106,33,8,32,13,33,4,65,0,33,9,3,64,32,7,32,3,57,3,112,32,7,32,2,57,3,104,32,7,32,1,57,3,96,32,17,32,8,32,9,65,3,110,65, + 104,108,106,43,3,0,32,7,65,248,2,106,32,16,32,4,106,65,15,113,65,3,116,106,43,3,0,162,160,33,17,32,8,65,8,106,33,8,32,9,65,1,106,33,9,32,4,65,127,106,34,4,65,127,71,13,0,11,32,7,65,248,0,106,32,16,65,15, + 113,65,3,116,106,32,17,57,3,0,32,16,65,1,106,34,16,32,12,76,13,0,11,11,32,7,65,216,0,106,66,0,55,3,0,32,7,65,32,106,65,48,106,66,0,55,3,0,32,7,65,200,0,106,66,0,55,3,0,32,7,65,192,0,106,66,0,55,3,0,32, + 7,65,56,106,66,0,55,3,0,32,7,65,48,106,66,0,55,3,0,32,7,65,32,106,65,8,106,66,0,55,3,0,32,7,66,0,55,3,32,32,18,32,11,65,177,7,106,34,19,32,18,65,129,120,74,34,8,27,33,20,68,0,0,0,0,0,0,240,63,68,0,0,0, + 0,0,0,96,3,32,8,27,33,21,32,6,65,2,106,33,22,65,47,32,11,107,33,23,65,48,32,11,107,33,24,32,18,65,128,8,72,33,25,32,11,65,233,119,106,34,26,65,255,7,75,33,27,32,11,65,103,106,33,28,32,18,65,184,112,74, + 33,29,32,18,65,130,120,72,33,30,32,12,33,14,2,64,3,64,32,7,65,248,0,106,32,14,34,16,65,15,113,34,31,65,3,116,106,43,3,0,33,17,2,64,32,16,65,1,72,34,15,13,0,32,16,65,15,106,33,8,65,0,33,4,3,64,32,4,65,15, + 113,65,2,116,33,9,2,64,2,64,32,17,68,0,0,0,0,0,0,112,62,162,34,32,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,32,170,33,14,12,1,11,65,128,128,128,128,120,33,14,11,32,7,65,32,106,32,9,106,33,9,2,64,2,64,32, + 17,32,14,183,34,32,68,0,0,0,0,0,0,112,193,162,160,34,17,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,17,170,33,14,12,1,11,65,128,128,128,128,120,33,14,11,32,9,32,14,54,2,0,32,7,65,248,0,106,32,8,65,15,113,65, + 3,116,106,43,3,0,32,32,160,33,17,32,4,65,1,106,33,4,32,8,65,127,106,34,8,65,15,71,13,0,11,11,2,64,2,64,2,64,2,64,32,25,13,0,32,17,68,0,0,0,0,0,0,224,127,162,33,17,32,27,13,1,32,26,33,8,12,3,11,32,30,13, + 1,32,18,33,8,12,2,11,32,17,68,0,0,0,0,0,0,224,127,162,33,17,32,18,65,253,23,32,18,65,253,23,72,27,65,130,112,106,33,8,12,1,11,32,17,68,0,0,0,0,0,0,96,3,162,33,17,2,64,32,18,65,184,112,77,13,0,32,19,33, + 8,12,1,11,32,17,68,0,0,0,0,0,0,96,3,162,33,17,32,18,65,240,104,32,18,65,240,104,74,27,65,146,15,106,33,8,11,2,64,2,64,32,17,32,8,65,255,7,106,173,66,52,134,191,162,34,17,32,17,68,0,0,0,0,0,0,192,63,162, + 156,68,0,0,0,0,0,0,32,192,162,160,34,17,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,17,170,33,13,12,1,11,65,128,128,128,128,120,33,13,11,32,17,32,13,183,161,33,17,2,64,2,64,2,64,2,64,2,64,32,18,65,1,72,34, + 33,13,0,32,7,65,32,106,32,16,65,127,106,65,15,113,65,2,116,106,34,8,32,8,40,2,0,34,8,32,8,32,24,117,34,8,32,24,116,107,34,4,54,2,0,32,4,32,23,117,33,34,32,8,32,13,106,33,13,12,1,11,2,64,32,18,13,0,32,7, + 65,32,106,32,16,65,127,106,65,15,113,65,2,116,106,40,2,0,65,23,117,33,34,12,1,11,65,2,33,34,65,0,33,35,32,17,68,0,0,0,0,0,0,224,63,102,69,13,3,12,1,11,32,34,65,0,76,13,1,11,2,64,2,64,32,15,69,13,0,65,0, + 33,14,12,1,11,65,0,33,8,65,0,33,14,3,64,32,7,65,32,106,32,8,65,15,113,65,2,116,106,34,15,40,2,0,33,4,65,255,255,255,7,33,9,2,64,2,64,32,14,65,1,113,13,0,65,128,128,128,8,33,9,32,4,13,0,65,0,33,14,12,1, + 11,32,15,32,9,32,4,107,54,2,0,65,1,33,14,11,32,16,32,8,65,1,106,34,8,71,13,0,11,11,2,64,32,33,13,0,65,255,255,255,3,33,8,2,64,2,64,32,28,14,2,1,0,2,11,65,255,255,255,1,33,8,11,32,7,65,32,106,32,16,65,127, + 106,65,15,113,65,2,116,106,34,4,32,4,40,2,0,32,8,113,54,2,0,11,32,13,65,1,106,33,13,32,34,65,2,71,13,0,68,0,0,0,0,0,0,240,63,32,17,161,33,17,65,2,33,35,32,14,69,13,1,2,64,2,64,32,25,13,0,68,0,0,0,0,0,0, + 224,127,33,32,32,26,33,8,32,27,69,13,1,32,18,65,253,23,32,18,65,253,23,72,27,65,130,112,106,33,8,68,0,0,0,0,0,0,240,127,33,32,12,1,11,32,21,33,32,32,20,33,8,32,29,13,0,32,18,65,240,104,32,18,65,240,104, + 74,27,65,146,15,106,33,8,68,0,0,0,0,0,0,0,0,33,32,11,32,17,32,32,32,8,65,255,7,106,173,66,52,134,191,162,161,33,17,12,1,11,32,34,33,35,11,2,64,32,17,68,0,0,0,0,0,0,0,0,98,13,0,2,64,32,16,65,127,106,34, + 8,32,12,72,13,0,65,0,33,4,3,64,32,7,65,32,106,32,8,65,15,113,65,2,116,106,40,2,0,32,4,114,33,4,32,8,65,127,106,34,8,32,12,78,13,0,11,32,4,69,13,0,32,18,33,11,3,64,32,11,65,104,106,33,11,32,7,65,32,106, + 32,16,65,127,106,34,16,65,15,113,65,2,116,106,40,2,0,69,13,0,12,4,11,11,32,22,33,8,32,16,33,14,3,64,32,14,65,1,106,33,14,32,8,65,15,113,33,4,32,8,65,127,106,33,8,32,7,65,32,106,32,4,65,2,116,106,40,2,0, + 69,13,0,11,32,16,65,1,106,34,15,32,14,74,13,1,3,64,32,7,65,248,2,106,32,16,32,5,106,65,15,113,65,3,116,106,32,7,65,184,4,106,32,15,32,10,106,65,194,0,111,34,8,65,194,0,106,32,8,32,8,65,0,72,27,65,2,116, + 106,40,2,0,183,57,3,0,68,0,0,0,0,0,0,0,0,33,17,2,64,32,5,65,1,72,13,0,65,0,33,8,32,7,65,8,106,33,4,32,5,33,9,3,64,32,7,32,3,57,3,24,32,7,32,2,57,3,16,32,7,32,1,57,3,8,32,17,32,4,32,8,65,3,110,65,104,108, + 106,43,3,0,32,7,65,248,2,106,32,16,32,9,106,65,15,113,65,3,116,106,43,3,0,162,160,33,17,32,4,65,8,106,33,4,32,8,65,1,106,33,8,32,9,65,127,106,34,9,13,0,11,11,32,7,65,248,0,106,32,15,65,15,113,65,3,116, + 106,32,17,57,3,0,32,16,65,1,106,33,16,32,15,65,1,106,34,15,32,14,74,13,2,12,0,11,11,11,2,64,2,64,2,64,65,24,32,11,107,34,8,65,128,8,72,13,0,32,17,68,0,0,0,0,0,0,224,127,162,33,17,65,153,120,32,11,107,34, + 4,65,255,7,75,13,1,32,4,33,8,12,2,11,32,8,65,130,120,78,13,1,32,17,68,0,0,0,0,0,0,96,3,162,33,17,2,64,32,8,65,184,112,77,13,0,65,225,7,32,11,107,33,8,12,2,11,32,17,68,0,0,0,0,0,0,96,3,162,33,17,32,8,65, + 240,104,32,8,65,240,104,74,27,65,146,15,106,33,8,12,1,11,32,17,68,0,0,0,0,0,0,224,127,162,33,17,32,8,65,253,23,32,8,65,253,23,72,27,65,130,112,106,33,8,11,2,64,2,64,32,17,32,8,65,255,7,106,173,66,52,134, + 191,162,34,17,68,0,0,0,0,0,0,112,65,102,69,13,0,32,31,65,2,116,33,4,2,64,2,64,32,17,68,0,0,0,0,0,0,112,62,162,34,3,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,3,170,33,8,12,1,11,65,128,128,128,128,120,33,8, + 11,32,7,65,32,106,32,4,106,33,4,2,64,2,64,32,17,32,8,183,68,0,0,0,0,0,0,112,193,162,160,34,17,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,17,170,33,9,12,1,11,65,128,128,128,128,120,33,9,11,32,4,32,9,54,2,0, + 32,16,65,1,106,34,16,65,15,113,33,31,12,1,11,2,64,2,64,32,17,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,17,170,33,8,12,1,11,65,128,128,128,128,120,33,8,11,32,18,33,11,11,32,7,65,32,106,32,31,65,2,116,106, + 32,8,54,2,0,11,2,64,2,64,32,11,65,128,8,72,13,0,68,0,0,0,0,0,0,224,127,33,17,32,11,65,129,120,106,34,8,65,255,7,77,13,1,32,11,65,253,23,32,11,65,253,23,72,27,65,130,112,106,33,8,68,0,0,0,0,0,0,240,127, + 33,17,12,1,11,68,0,0,0,0,0,0,240,63,33,17,2,64,32,11,65,130,120,72,13,0,32,11,33,8,12,1,11,2,64,32,11,65,184,112,77,13,0,32,11,65,201,7,106,33,8,68,0,0,0,0,0,0,96,3,33,17,12,1,11,32,11,65,240,104,32,11, + 65,240,104,74,27,65,146,15,106,33,8,68,0,0,0,0,0,0,0,0,33,17,11,68,0,0,0,0,0,0,0,0,33,2,68,0,0,0,0,0,0,0,0,33,3,2,64,32,16,65,0,72,13,0,32,17,32,8,65,255,7,106,173,66,52,134,191,162,33,17,32,16,33,8,3, + 64,32,7,65,248,0,106,32,8,65,15,113,34,4,65,3,116,106,32,17,32,7,65,32,106,32,4,65,2,116,106,40,2,0,183,162,57,3,0,32,17,68,0,0,0,0,0,0,112,62,162,33,17,32,8,65,127,106,34,8,65,127,71,13,0,11,68,0,0,0, + 0,0,0,0,0,33,3,32,16,65,0,72,13,0,2,64,2,64,32,12,65,127,76,13,0,32,16,33,8,3,64,2,64,2,64,32,16,32,8,34,4,107,34,9,65,0,78,13,0,68,0,0,0,0,0,0,0,0,33,17,12,1,11,68,0,0,0,0,0,0,0,0,33,17,65,0,33,8,3,64, + 32,17,32,7,65,248,3,106,32,8,65,7,113,65,3,116,106,43,3,0,32,7,65,248,0,106,32,4,32,8,106,65,15,113,65,3,116,106,43,3,0,162,160,33,17,32,8,65,1,106,34,8,32,12,74,13,1,32,8,32,9,76,13,0,11,11,32,7,65,248, + 1,106,32,9,65,15,113,65,3,116,106,32,17,57,3,0,32,4,65,127,106,33,8,32,4,65,0,74,13,0,12,2,11,11,32,16,65,1,106,33,4,65,0,33,8,3,64,32,7,65,248,1,106,32,8,65,15,113,65,3,116,106,66,0,55,3,0,32,4,32,8,65, + 1,106,34,8,71,13,0,11,11,68,0,0,0,0,0,0,0,0,33,3,32,16,65,0,72,13,0,68,0,0,0,0,0,0,0,0,33,3,32,16,33,8,3,64,32,3,32,7,65,248,1,106,32,8,65,15,113,65,3,116,106,43,3,0,160,33,3,32,8,65,127,106,34,8,65,127, + 71,13,0,11,11,2,64,32,6,69,13,0,32,7,43,3,248,1,32,3,161,33,17,2,64,32,16,65,1,72,13,0,65,1,33,8,3,64,32,17,32,7,65,248,1,106,32,8,65,15,113,65,3,116,106,43,3,0,160,33,17,32,8,65,1,106,34,8,32,16,76,13, + 0,11,11,32,17,154,32,17,32,35,27,33,2,11,32,0,32,2,57,3,16,32,0,32,13,65,7,113,54,2,0,32,0,32,3,154,32,3,32,35,27,57,3,8,32,7,65,192,6,106,36,128,128,128,128,0,11,187,4,5,1,125,2,127,1,125,1,124,1,125, + 2,64,2,64,2,64,2,64,2,64,2,64,2,64,32,0,67,0,0,160,65,32,0,67,0,0,160,65,93,27,34,0,32,0,146,34,1,188,34,2,65,255,255,255,255,7,113,34,3,65,208,216,186,149,4,73,13,0,2,64,32,3,65,128,128,128,252,7,77,13, + 0,32,1,33,0,12,7,11,2,64,32,2,65,0,72,13,0,32,3,65,152,228,197,149,4,73,13,0,32,1,67,0,0,0,127,148,33,0,12,7,11,32,2,65,127,74,13,1,67,0,0,0,0,33,0,32,3,65,181,227,191,150,4,73,13,1,12,6,11,2,64,32,3,65, + 152,228,197,245,3,75,13,0,32,3,65,128,128,128,200,3,77,13,3,65,0,33,3,67,0,0,0,0,33,4,32,1,33,0,12,5,11,32,3,65,146,171,148,252,3,77,13,1,11,2,64,68,0,0,0,0,0,0,224,63,68,0,0,0,0,0,0,224,191,32,2,65,127, + 74,27,32,1,67,59,170,184,63,148,187,160,34,5,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,5,170,33,3,12,3,11,65,128,128,128,128,120,33,3,12,2,11,65,1,65,127,32,2,65,127,74,27,33,3,12,1,11,32,1,67,0,0,128,63, + 146,33,0,12,2,11,32,1,32,3,178,34,0,67,0,114,49,191,148,146,34,1,32,0,67,142,190,191,53,148,34,4,147,33,0,11,32,1,32,0,32,0,32,0,32,0,148,34,6,32,6,67,21,82,53,187,148,67,143,170,42,62,146,148,147,34,6, + 148,67,0,0,0,64,32,6,147,149,32,4,147,146,67,0,0,128,63,146,33,0,2,64,2,64,32,3,65,128,1,72,13,0,32,0,67,0,0,0,127,148,33,1,32,3,65,129,127,106,34,2,65,255,0,77,13,1,32,1,67,0,0,0,127,148,33,1,32,3,65, + 253,2,32,3,65,253,2,72,27,65,130,126,106,33,2,12,1,11,32,0,33,1,32,3,33,2,32,3,65,130,127,78,13,0,32,0,67,0,0,128,12,148,33,1,2,64,32,3,65,155,126,77,13,0,32,3,65,230,0,106,33,2,12,1,11,32,1,67,0,0,128, + 12,148,33,1,32,3,65,182,125,32,3,65,182,125,74,27,65,204,1,106,33,2,11,32,1,32,2,65,23,116,65,128,128,128,252,3,106,190,148,32,0,32,3,27,33,0,11,32,0,67,0,0,128,191,146,32,0,67,0,0,128,63,146,149,11,11, + 156,130,128,128,0,2,0,65,0,11,8,0,0,0,0,0,0,0,0,0,65,16,11,136,2,131,249,162,0,68,78,110,0,252,41,21,0,209,87,39,0,221,52,245,0,98,219,192,0,60,153,149,0,65,144,67,0,99,81,254,0,187,222,171,0,183,97,197, + 0,58,110,36,0,210,77,66,0,73,6,224,0,9,234,46,0,28,146,209,0,235,29,254,0,41,177,28,0,232,62,167,0,245,53,130,0,68,187,46,0,156,233,132,0,180,38,112,0,65,126,95,0,214,145,57,0,83,131,57,0,156,244,57,0, + 139,95,132,0,40,249,189,0,248,31,59,0,222,255,151,0,15,152,5,0,17,47,239,0,10,90,139,0,109,31,109,0,207,126,54,0,9,203,39,0,70,79,183,0,158,102,63,0,45,234,95,0,186,39,117,0,229,235,199,0,61,123,241,0, + 247,57,7,0,146,82,138,0,251,107,234,0,31,177,95,0,8,93,141,0,48,3,86,0,123,252,70,0,240,171,107,0,32,188,207,0,54,244,154,0,227,169,29,0,94,97,145,0,8,27,230,0,133,153,101,0,160,20,95,0,141,64,104,0,128, + 216,255,0,39,115,77,0,6,6,49,0,202,86,21,0,201,168,115,0,123,226,96,0,107,140,192,0,0,138,134,128,128,0,7,108,105,110,107,105,110,103,2,8,200,133,128,128,0,25,0,32,2,26,95,115,101,110,100,69,118,101,110, + 116,95,116,101,115,116,83,105,103,110,97,108,83,104,97,112,101,0,32,3,30,95,115,101,110,100,69,118,101,110,116,95,116,101,115,116,83,105,103,110,97,108,70,114,101,113,117,101,110,99,121,1,2,12,46,76,95, + 102,114,101,113,117,101,110,99,121,0,0,8,0,2,4,32,46,76,115,116,100,95,95,111,115,99,105,108,108,97,116,111,114,115,95,95,115,101,116,70,114,101,113,117,101,110,99,121,0,32,5,17,95,115,101,110,100,69,118, + 101,110,116,95,118,111,108,117,109,101,0,2,6,54,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101,110, + 116,97,116,105,111,110,115,95,95,112,111,119,0,2,7,67,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109, + 101,110,116,97,116,105,111,110,115,95,95,104,101,108,112,101,114,115,95,95,115,99,97,108,98,110,102,0,32,8,22,95,115,101,110,100,69,118,101,110,116,95,102,114,101,113,117,101,110,99,121,73,110,0,32,9,20, + 95,115,101,110,100,69,118,101,110,116,95,113,117,97,108,105,116,121,73,110,0,32,10,17,95,115,101,110,100,69,118,101,110,116,95,109,111,100,101,73,110,0,32,11,15,95,115,101,110,100,69,118,101,110,116,95, + 110,108,73,110,0,32,12,16,95,115,101,110,100,69,118,101,110,116,95,115,97,116,73,110,0,32,13,19,95,115,101,110,100,69,118,101,110,116,95,114,97,116,101,72,122,73,110,0,32,14,21,95,115,101,110,100,69,118, + 101,110,116,95,109,111,100,68,101,112,116,104,73,110,0,32,15,18,95,115,101,110,100,69,118,101,110,116,95,115,104,97,112,101,73,110,0,32,16,10,105,110,105,116,105,97,108,105,115,101,0,32,17,12,97,100,118, + 97,110,99,101,66,108,111,99,107,2,16,0,0,2,18,54,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101, + 110,116,97,116,105,111,110,115,95,95,115,105,110,0,2,19,30,46,76,115,116,100,95,95,111,115,99,105,108,108,97,116,111,114,115,95,95,110,101,120,116,83,113,117,97,114,101,0,2,20,81,46,76,115,116,100,95,95, + 105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101,110,116,97,116,105,111,110,115,95,95,104,101,108,112,101,114,115,95,95,114, + 101,109,97,105,110,100,101,114,80,105,79,118,101,114,50,76,97,114,103,101,0,2,21,23,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,116,97,110,104,1,2,13,46,76,95,95,99,111,110,115, + 116,97,110,116,95,1,0,136,2,0,16,0,0,16,1,5,173,128,128,128,0,2,17,46,98,115,115,46,46,76,95,102,114,101,113,117,101,110,99,121,3,0,21,46,114,111,100,97,116,97,46,46,76,95,95,99,111,110,115,116,97,110, + 116,95,4,0,0,150,129,128,128,0,10,114,101,108,111,99,46,67,79,68,69,5,33,3,71,2,0,0,80,3,0,225,4,5,3,162,5,2,0,0,241,18,6,3,216,21,2,0,3,185,22,2,0,0,217,22,3,3,139,23,2,0,7,225,24,17,7,236,24,17,0,136, + 26,18,0,157,26,19,0,218,26,19,0,159,32,18,0,237,33,5,3,214,37,2,0,0,228,52,20,0,208,62,21,0,189,65,21,7,224,67,17,7,240,67,17,7,251,67,17,0,240,73,20,7,173,78,17,7,235,83,17,7,247,83,17,4,131,84,22,0,0, + 140,84,23,0,162,85,24,0,180,85,24,0,198,85,24,7,197,109,17,]); + } +} diff --git a/assets/example_patches/PirkleFilters/index.html b/assets/example_patches/PirkleFilters/index.html new file mode 100644 index 00000000..d0928b49 --- /dev/null +++ b/assets/example_patches/PirkleFilters/index.html @@ -0,0 +1,202 @@ + + + + Cmajor Patch + + + +
+
+
+
+
+
+ vafilters + vafilters by will pirkle + + - Click to Start - +
+
+ +
+
+ + + + + + diff --git a/assets/example_patches/Replicant/README.md b/assets/example_patches/Replicant/README.md new file mode 100644 index 00000000..4bc2ee09 --- /dev/null +++ b/assets/example_patches/Replicant/README.md @@ -0,0 +1,23 @@ +### Auto-generated HTML & Javascript for Cmajor Patch "Replicant" + +This folder contains some self-contained HTML/Javascript files that play and show a Cmajor +patch using WebAssembly and WebAudio. + +For `index.html` to display correctly, this folder needs to be served as HTTP, so if you're +running it locally, you'll need to start a webserver that serves this folder, and then +point your browser at whatever URL your webserver provides. For example, you could run +`python3 -m http.server` in this folder, and then browse to the address it chooses. + +The files have all been generated using the Cmajor command-line tool: +``` +cmaj generate --target=webaudio --output= +``` + +- `index.html` is a minimal page that creates the javascript object that implements the patch, + connects it to the default audio and MIDI devices, and displays its view. +- `cmaj_Replicant.js` - this is the Javascript wrapper class for the patch, encapsulating its + DSP as webassembly, and providing an API that is used to both render the audio and + control its properties. +- `cmaj_api` - this folder contains javascript helper modules and resources. + +To learn more about Cmajor, visit [cmajor.dev](cmajor.dev) diff --git a/assets/example_patches/Replicant/cmaj_Replicant.js b/assets/example_patches/Replicant/cmaj_Replicant.js new file mode 100644 index 00000000..0ffcec60 --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_Replicant.js @@ -0,0 +1,426 @@ +//============================================================================== +// +// This file contains a Javascript/Webassembly/WebAudio export of the Cmajor +// patch 'replicant.cmajorpatch'. +// +// This file was auto-generated by the Cmajor toolkit v1.0 +// +// To use it, import this module into your HTML/Javascript code and call +// `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection +// object that is returned is a PatchConnection with some extra functionality +// to let you connect it to web audio/MIDI. +// +// For more details about Cmajor, visit https://cmajor.dev +// +//============================================================================== + +import * as helpers from "./cmaj_api/cmaj_audio_worklet_helper.js" + + +//============================================================================== +/** This exports the patch's manifest, in case a caller needs to find out about its properties. + */ +export const manifest = +{ + "CmajorVersion": 1, + "ID": "Replicant", + "version": "1.0", + "name": "Replicant", + "description": "Cmajor port of code by Mick Grierson", + "category": "synth", + "manufacturer": "Oli Larkin", + "website": "https://www.olilarkin.co.uk", + "isInstrument": false, + "source": "replicant.cmajor" +}; + +/** Returns the patch's output endpoint list */ +export function getOutputEndpoints() { return Replicant.prototype.getOutputEndpoints(); } + +/** Returns the patch's input endpoint list */ +export function getInputEndpoints() { return Replicant.prototype.getInputEndpoints(); } + +//============================================================================== +/** Creates an audio worklet node for the patch with the given name, attaches it + * to the audio context provided, and returns an object containing the node + * and a PatchConnection class to control it. + * + * @param {AudioContext} audioContext - a web audio AudioContext object + * @param {string} workletName - the name to give the new worklet that is created + * @returns {AudioWorkletPatchConnection} an AudioWorkletPatchConnection which has been initialised + */ +export async function createAudioWorkletNodePatchConnection (audioContext, workletName) +{ + const connection = new helpers.AudioWorkletPatchConnection (manifest); + await connection.initialise (Replicant, audioContext, workletName, Date.now() & 0x7fffffff); + return connection; +} + +/*********************************************************************************** + * + * A Javascript/Webassembly implementation of the Cmajor processor 'Replicant::Replicant'. + * + * This class was auto-generated by the Cmajor toolkit. + * + * To use it, construct an instance of this class, and call `initialise()` to + * asynchronously prepare it for use. Once initialised, the class provides + * appropriate setter/getter methods for reading/writing data to its endpoints, + * and an `advance()` method to render the next block. + * + * This roughly mirrors functionality of the cmajor Performer API - see the + * C++ API classes and Cmajor docs for more information about how this is used. + */ +class Replicant +{ + /** After constructing one of these objects, call its + * initialise() method to prepare it for use. + */ + constructor() + { + } + + //============================================================================== + /** Prepares this processor for use. + * + * @param {number} sessionID - A unique integer ID which will be used for `processor.session`. + * @param {number} frequency - The frequency in Hz that the processor will be expected to run at. + */ + async initialise (sessionID, frequency) + { + if (! ((sessionID ^ 0) > 1)) + throw new Error ("initialise() requires a valid non-zero session ID argument"); + + if (! (frequency > 1)) + throw new Error ("initialise() requires a valid frequency argument"); + + const memory = new WebAssembly.Memory ({ initial: 5 }); + const stack = new WebAssembly.Global ({ value: "i32", mutable: true }, 76848); + + const imports = { + env: { + __linear_memory: memory, + __memory_base: 0, + __stack_pointer: stack, + __table_base: 0, + memcpy: (dst, src, len) => { this.byteMemory.copyWithin (dst, src, src + len); return dst; }, + memmove: (dst, src, len) => { this.byteMemory.copyWithin (dst, src, src + len); return dst; }, + memset: (dst, value, len) => { this.byteMemory.fill (value, dst, dst + len); return dst; } + }, + }; + + const result = await WebAssembly.instantiate (this._getWasmBytes(), imports); + this.instance = result.instance; + const exports = this.instance.exports; + + const memoryBuffer = exports.memory?.buffer || memory.buffer; + this.byteMemory = new Uint8Array (memoryBuffer); + this.memoryDataView = new DataView (memoryBuffer); + + if (exports.advanceBlock) + this._advance = numFrames => exports.advanceBlock (76848, 254576, numFrames); + else + this._advance = () => exports.advanceOneFrame (76848, 254576); + + exports.initialise?.(76848, sessionID, frequency); + return true; + } + + //============================================================================== + /** Advances the processor by a number of frames. + * + * Before calling `advance()` you should use the appropriate functions to + * push data and events into the processor's input endpoints. After calling + * `advance()` you can use its output endpoint access functions to read the + * results. + * + * @param {number} numFrames - An integer number of frames to advance. + * This must be greater than zero. + */ + advance (numFrames) + { + this.byteMemory.fill (0, 254576, 254576 + numFrames * 4); + this._advance (numFrames); + } + + //============================================================================== + /** Returns an object which encapsulates the state of the patch at this point. + * The state can be restored by passing this object to `restoreState()`. + */ + getState() + { + return { memory: this.byteMemory.slice(0) }; + } + + /** Restores the patch to a state that was previously saved by a call to `getState()` + */ + restoreState (savedState) + { + if (savedState?.memory && savedState.memory?.length === this.byteMemory.length) + this.byteMemory.set (savedState.memory); + else + throw Error ("restoreState(): not a valid state object"); + } + + /** Returns a list of the output endpoints that this processor exposes. + * @returns {Array} + */ + getOutputEndpoints() + { + return [ + { + "endpointID": "out", + "endpointType": "stream", + "dataType": { + "type": "float32" + }, + "purpose": "audio out", + "numAudioChannels": 1 + } + ]; + } + + /** Returns a list of the input endpoints that this processor exposes. + * @returns {Array} + */ + getInputEndpoints() + { + return []; + } + + /** Returns a frame from the output stream "out" + * + * @param {number} frameIndex - the index of the frame to fetch + */ + getOutputFrame_out (frameIndex) + { + return this.memoryDataView.getFloat32 (254576 + frameIndex * 4, true); + } + + /** Copies frames from the output stream "out" into a destination array. + * + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied + * @param {number} maxNumFramesToRead - The maximum number of frames to copy + */ + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) + { + let source = 254576; + let numDestChans = destChannelArrays.length; + + if (maxNumFramesToRead > 512) + maxNumFramesToRead = 512; + + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; + + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + } + + //============================================================================== + // Code beyond this point is private internal implementation detail + + //============================================================================== + /** @access private */ + _getWasmBytes() + { + return new Uint8Array([0,97,115,109,1,0,0,0,1,170,128,128,128,0,7,96,3,127,127,124,0,96,3,127,127,127,0,96,1,125,1,125,96,2,125,125,1,125,96,3,127,127,127,1,127,96,2,127,125,0,96,2,125,127,1,125,2,201, + 128,128,128,0,4,3,101,110,118,15,95,95,108,105,110,101,97,114,95,109,101,109,111,114,121,2,0,1,3,101,110,118,15,95,95,115,116,97,99,107,95,112,111,105,110,116,101,114,3,127,1,3,101,110,118,6,109,101,109, + 99,112,121,0,4,3,101,110,118,6,109,101,109,115,101,116,0,4,3,136,128,128,128,0,7,0,1,2,3,5,2,6,7,157,128,128,128,0,2,10,105,110,105,116,105,97,108,105,115,101,0,2,12,97,100,118,97,110,99,101,66,108,111, + 99,107,0,3,12,129,128,128,128,0,3,10,178,199,128,128,0,7,13,0,65,0,32,2,57,3,128,128,128,128,0,11,177,32,10,12,127,1,125,1,127,1,124,3,127,3,125,1,127,1,124,1,125,1,124,35,128,128,128,128,0,65,16,107,34, + 3,36,128,128,128,128,0,2,64,32,0,40,2,0,32,2,70,13,0,32,0,65,152,10,106,33,4,32,0,65,144,10,106,33,5,32,0,65,212,9,106,33,6,32,0,65,144,9,106,33,7,32,0,65,136,1,106,33,8,32,0,65,220,0,106,33,9,32,0,65, + 48,106,33,10,32,0,65,24,106,33,11,32,0,65,228,9,106,34,12,65,8,106,33,13,32,12,65,16,106,33,14,3,64,67,0,0,0,0,33,15,2,64,32,0,40,2,132,1,34,16,65,127,70,13,0,2,64,32,16,65,0,74,13,0,32,0,65,8,54,2,84, + 32,0,32,10,54,2,80,32,0,65,0,54,2,76,32,0,66,5,55,2,68,32,0,66,130,128,128,128,208,0,55,2,60,32,0,66,128,128,128,128,240,0,55,2,52,32,0,66,4,55,2,44,32,0,32,11,54,2,40,32,0,66,187,128,128,128,192,7,55, + 2,32,32,0,66,185,128,128,128,144,7,55,2,24,32,9,66,0,55,2,0,32,9,65,8,106,66,0,55,2,0,32,9,65,16,106,65,0,54,2,0,2,64,2,64,65,0,43,3,128,128,128,128,0,68,0,0,0,0,0,0,34,64,163,34,17,153,68,0,0,0,0,0,0, + 224,65,99,69,13,0,32,17,170,33,18,12,1,11,65,128,128,128,128,120,33,18,11,32,0,32,18,54,2,88,11,3,64,2,64,2,64,32,16,65,1,72,13,0,32,0,40,2,128,1,33,18,65,0,33,16,12,1,11,65,0,33,18,65,0,33,19,2,64,32, + 0,40,2,44,34,20,69,13,0,32,0,40,2,40,32,0,40,2,108,65,4,111,32,20,111,34,19,65,31,117,32,20,113,32,19,106,65,2,116,106,40,2,0,33,19,11,2,64,32,0,40,2,84,34,20,69,13,0,32,0,40,2,80,32,0,40,2,104,32,20,111, + 34,18,65,31,117,32,20,113,32,18,106,65,2,116,106,40,2,0,33,18,11,2,64,2,64,32,19,32,18,106,65,175,127,106,178,67,171,170,170,61,148,34,15,188,34,19,65,255,255,255,255,7,113,34,18,13,0,67,0,0,128,63,33, + 21,12,1,11,2,64,32,18,65,129,128,128,252,7,73,13,0,32,15,67,0,0,0,64,146,33,21,12,1,11,2,64,2,64,32,18,65,128,128,128,252,3,70,13,0,32,18,65,128,128,128,252,7,71,13,1,32,15,67,0,0,0,0,32,19,65,127,74,27, + 33,21,12,2,11,67,0,0,0,64,67,0,0,0,63,32,19,65,127,74,27,33,21,12,1,11,2,64,32,19,65,128,128,128,248,3,70,13,0,67,0,0,128,64,33,21,32,19,65,128,128,128,128,4,70,13,1,2,64,32,18,65,129,128,128,232,4,73, + 13,0,67,0,0,128,127,67,0,0,0,0,32,19,65,0,74,27,33,21,12,2,11,67,0,0,128,127,33,21,32,15,67,0,0,0,0,148,32,15,32,19,65,128,96,113,190,34,22,147,146,34,15,32,22,146,34,23,188,34,18,65,128,128,128,152,4, + 74,13,1,2,64,2,64,2,64,32,18,65,128,128,128,152,4,71,13,0,65,128,128,128,152,4,33,20,32,15,67,60,170,56,51,146,32,23,32,22,147,94,69,13,1,12,4,11,67,0,0,0,0,33,21,32,18,65,255,255,255,255,7,113,34,20,65, + 128,128,216,152,4,75,13,3,2,64,32,18,65,128,128,216,152,124,71,13,0,32,15,32,23,32,22,147,95,13,4,11,65,0,33,19,32,20,65,128,128,128,248,3,77,13,1,11,65,0,65,128,128,128,4,32,20,65,23,118,65,130,127,106, + 118,32,18,106,34,20,65,255,255,255,3,113,65,128,128,128,4,114,65,150,1,32,20,65,23,118,65,255,1,113,34,24,107,118,34,19,107,32,19,32,18,65,0,72,27,33,19,32,15,32,22,65,128,128,128,124,32,24,65,129,127, + 106,117,32,20,113,190,147,34,22,146,188,33,18,11,2,64,2,64,2,64,32,19,65,23,116,32,18,65,128,128,126,113,190,34,21,67,0,114,49,63,148,34,23,32,21,67,140,190,191,53,148,32,15,32,21,32,22,147,147,67,24,114, + 49,63,148,146,34,22,146,34,21,32,21,32,21,32,21,32,21,148,34,15,32,15,32,15,32,15,32,15,67,76,187,49,51,148,67,14,234,221,181,146,148,67,85,179,138,56,146,148,67,97,11,54,187,146,148,67,171,170,42,62,146, + 148,147,34,15,148,32,15,67,0,0,0,192,146,149,32,22,32,21,32,23,147,147,34,15,32,21,32,15,148,146,147,147,67,0,0,128,63,146,34,21,188,106,34,18,65,255,255,255,3,74,13,0,2,64,32,19,65,128,1,72,13,0,32,21, + 67,0,0,0,127,148,33,21,32,19,65,129,127,106,34,18,65,255,0,75,13,2,32,18,33,19,12,3,11,32,19,65,130,127,78,13,2,32,21,67,0,0,128,12,148,33,21,2,64,32,19,65,155,126,77,13,0,32,19,65,230,0,106,33,19,12,3, + 11,32,21,67,0,0,128,12,148,33,21,32,19,65,182,125,32,19,65,182,125,74,27,65,204,1,106,33,19,12,2,11,32,18,190,33,21,12,3,11,32,21,67,0,0,0,127,148,33,21,32,19,65,253,2,32,19,65,253,2,72,27,65,130,126,106, + 33,19,11,32,21,32,19,65,23,116,65,128,128,128,252,3,106,190,148,33,21,12,1,11,67,243,4,181,63,33,21,11,32,0,65,128,128,128,252,3,54,2,124,32,0,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,34,17, + 32,21,67,0,0,220,67,148,187,162,34,25,182,56,2,112,32,0,32,17,68,51,51,51,51,51,51,211,63,162,182,56,2,120,32,0,32,25,68,0,0,0,0,0,0,224,63,162,182,56,2,116,32,0,40,2,88,33,18,11,32,0,32,18,65,127,106, + 34,18,54,2,128,1,2,64,32,18,65,0,72,13,0,32,0,32,0,42,2,112,32,0,42,2,92,146,16,132,128,128,128,0,34,21,56,2,92,32,0,32,0,42,2,120,32,0,42,2,100,146,16,132,128,128,128,0,34,15,56,2,100,32,21,67,164,112, + 125,63,67,0,0,128,63,32,15,32,15,146,67,0,0,128,191,146,139,147,34,15,67,10,215,35,60,151,32,15,67,164,112,125,63,94,27,34,15,146,16,132,128,128,128,0,33,22,32,0,42,2,124,33,23,32,0,32,0,42,2,116,32,0, + 42,2,96,146,16,132,128,128,128,0,34,26,56,2,96,32,26,67,102,102,102,63,16,133,128,128,128,0,33,26,32,0,65,1,54,2,132,1,32,0,32,0,42,2,124,67,0,0,128,191,32,0,40,2,88,178,149,146,56,2,124,32,26,67,154,153, + 153,62,148,32,23,32,21,32,22,147,32,15,146,34,21,32,21,146,67,0,0,128,191,146,67,154,153,153,62,148,148,67,0,0,0,0,146,146,33,15,12,2,11,32,0,32,0,40,2,108,65,1,106,65,31,113,34,18,54,2,108,32,18,13,0, + 32,0,32,0,40,2,104,65,1,106,65,7,113,54,2,104,12,0,11,11,67,0,0,0,0,33,22,67,0,0,0,0,33,21,2,64,32,0,40,2,148,10,34,18,65,127,70,13,0,32,5,33,19,2,64,32,18,65,0,74,13,0,32,8,65,160,130,128,128,0,65,128, + 8,16,128,128,128,128,0,33,18,32,0,65,15,54,2,208,9,32,0,32,7,54,2,204,9,32,0,66,188,128,128,128,144,7,55,2,196,9,32,0,66,185,128,128,128,240,6,55,2,188,9,32,0,66,192,128,128,128,224,7,55,2,180,9,32,0,66, + 190,128,128,128,144,8,55,2,172,9,32,0,66,194,128,128,128,128,8,55,2,164,9,32,0,66,192,128,128,128,176,8,55,2,156,9,32,0,66,195,128,128,128,144,8,55,2,148,9,32,0,66,128,130,128,128,208,8,55,2,140,9,32,0, + 32,18,54,2,136,9,32,0,65,1,58,0,224,9,32,0,66,0,55,2,216,9,32,12,66,0,55,2,0,32,13,66,0,55,2,0,32,14,66,0,55,2,0,2,64,2,64,65,0,43,3,128,128,128,128,0,68,0,0,0,0,0,0,34,64,163,34,17,153,68,0,0,0,0,0,0, + 224,65,99,69,13,0,32,17,170,33,18,12,1,11,65,128,128,128,128,120,33,18,11,32,0,32,18,54,2,212,9,32,0,65,0,54,2,128,10,32,0,66,0,55,2,136,10,32,0,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,34, + 17,68,0,0,0,0,0,0,16,64,162,182,56,2,252,9,32,0,32,17,68,154,153,153,153,153,153,233,63,162,182,56,2,132,10,32,6,33,19,11,32,5,32,19,40,2,0,65,127,106,34,18,54,2,0,2,64,32,18,65,127,74,13,0,3,64,32,0,32, + 0,40,2,216,9,65,1,106,65,255,1,113,34,18,54,2,216,9,2,64,32,18,13,0,32,0,65,128,128,128,252,3,54,2,140,10,11,32,0,65,1,58,0,224,9,32,0,32,0,40,2,212,9,65,127,106,34,18,54,2,144,10,32,18,65,0,72,13,0,11, + 11,2,64,32,0,40,2,140,9,34,18,69,13,0,32,0,40,2,136,9,32,0,40,2,216,9,32,18,111,34,19,65,31,117,32,18,113,32,19,106,65,2,116,106,40,2,0,65,1,72,13,0,32,0,45,0,224,9,69,13,0,65,0,33,19,32,0,32,0,40,2,220, + 9,34,20,65,1,106,65,15,111,34,18,65,15,106,32,18,32,18,65,0,72,27,54,2,220,9,2,64,32,0,40,2,208,9,34,18,69,13,0,32,0,40,2,204,9,32,20,32,18,111,34,19,65,31,117,32,18,113,32,19,106,65,2,116,106,40,2,0,33, + 19,11,32,0,65,0,54,2,248,9,32,0,32,19,54,2,136,10,32,0,65,0,58,0,224,9,11,32,0,32,0,42,2,248,9,32,0,42,2,252,9,146,34,21,56,2,248,9,32,0,32,0,42,2,128,10,67,164,112,125,63,148,67,10,215,35,60,32,21,67, + 0,0,0,0,151,67,10,215,35,60,148,32,21,67,0,0,128,63,94,27,146,34,21,56,2,128,10,32,0,42,2,140,10,33,23,32,0,32,0,42,2,132,10,32,0,42,2,244,9,146,16,132,128,128,128,0,34,26,56,2,244,9,32,23,32,21,148,33, + 21,2,64,2,64,32,26,67,219,15,201,64,148,34,23,188,34,19,65,255,255,255,255,7,113,34,18,65,218,159,164,250,3,75,13,0,32,23,32,23,187,34,25,32,25,162,34,17,32,25,162,34,27,32,17,32,17,162,162,32,17,68,167, + 70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,27,32,17,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,32,25,160,160,182,32,18,65,128,128,128,204,3, + 73,27,33,23,12,1,11,2,64,32,18,65,209,167,237,131,4,75,13,0,32,23,187,33,17,2,64,32,18,65,228,151,219,128,4,79,13,0,68,0,0,0,0,0,0,240,63,32,17,68,24,45,68,84,251,33,249,63,160,34,25,32,25,162,34,25,68, + 129,94,12,253,255,255,223,63,162,161,32,25,32,25,162,34,27,68,66,58,5,225,83,85,165,63,162,160,32,25,32,27,162,32,25,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140, + 68,0,0,0,0,0,0,240,63,32,17,68,24,45,68,84,251,33,249,191,160,34,17,32,17,162,34,17,68,129,94,12,253,255,255,223,63,162,161,32,17,32,17,162,34,25,68,66,58,5,225,83,85,165,63,162,160,32,17,32,25,162,32, + 17,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,32,19,65,0,72,27,33,23,12,2,11,68,24,45,68,84,251,33,9,64,68,24,45,68,84,251,33,9,192,32,19,65,0,72,27,32,17,160,34, + 25,32,25,162,34,17,32,25,154,162,34,27,32,17,32,17,162,162,32,17,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,27,32,17,68,178,251,110,137,16,17,129,63,162,68,119,172,203, + 84,85,85,197,191,160,162,32,25,161,160,182,33,23,12,1,11,2,64,2,64,2,64,32,18,65,214,227,136,135,4,73,13,0,32,18,65,255,255,255,251,7,77,13,1,32,23,32,23,147,33,23,12,3,11,32,23,187,33,17,32,18,65,224, + 219,191,133,4,73,13,1,68,24,45,68,84,251,33,25,64,68,24,45,68,84,251,33,25,192,32,19,65,0,72,27,32,17,160,34,25,32,25,32,25,162,34,17,162,34,27,32,17,32,17,162,162,32,17,68,167,70,59,140,135,205,198,62, + 162,68,116,231,202,226,249,0,42,191,160,162,32,25,32,27,32,17,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,182,33,23,12,2,11,32,3,32,23,16,134,128,128,128,0,32,3, + 43,3,8,33,17,2,64,2,64,2,64,2,64,32,3,40,2,0,14,3,0,1,2,3,11,32,17,32,17,32,17,162,34,25,162,34,27,32,25,32,25,162,162,32,25,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162, + 32,17,32,27,32,25,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,182,33,23,12,4,11,32,17,32,17,162,34,17,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63, + 160,32,17,32,17,162,34,25,68,66,58,5,225,83,85,165,63,162,160,32,17,32,25,162,32,17,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,33,23,12,3,11,32,17,32,17,162,34,25, + 32,17,154,162,34,27,32,25,32,25,162,162,32,25,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,27,32,25,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191, + 160,162,32,17,161,160,182,33,23,12,2,11,32,17,32,17,162,34,17,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,17,32,17,162,34,25,68,66,58,5,225,83,85,165,63,162,160,32,17,32,25,162,32, + 17,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140,33,23,12,1,11,68,0,0,0,0,0,0,240,63,32,17,68,210,33,51,127,124,217,18,64,160,34,25,32,25,162,34,25,68,129,94,12, + 253,255,255,223,63,162,161,32,25,32,25,162,34,27,68,66,58,5,225,83,85,165,63,162,160,32,25,32,27,162,32,25,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,68,0,0,0,0, + 0,0,240,63,32,17,68,210,33,51,127,124,217,18,192,160,34,17,32,17,162,34,17,68,129,94,12,253,255,255,223,63,162,161,32,17,32,17,162,34,25,68,66,58,5,225,83,85,165,63,162,160,32,17,32,25,162,32,17,68,105, + 80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140,32,19,65,0,72,27,33,23,11,32,21,32,23,67,205,204,204,61,148,148,34,23,32,0,40,2,136,10,178,146,67,0,0,138,194,146,67,171, + 170,170,61,148,16,135,128,128,128,0,33,26,32,0,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,32,26,67,0,0,220,67,148,187,162,182,56,2,228,9,32,23,32,0,40,2,136,10,178,146,67,205,204,204,61,146, + 67,0,0,138,194,146,67,171,170,170,61,148,16,135,128,128,128,0,33,23,32,0,68,0,0,0,0,0,0,240,63,65,0,43,3,128,128,128,128,0,163,32,23,67,0,0,220,67,148,187,162,182,56,2,232,9,32,0,32,0,42,2,228,9,32,0,42, + 2,236,9,146,16,132,128,128,128,0,34,23,56,2,236,9,32,23,67,102,102,102,63,16,133,128,128,128,0,33,23,32,0,32,0,42,2,232,9,32,0,42,2,240,9,146,16,132,128,128,128,0,34,26,56,2,240,9,32,26,67,195,245,104, + 63,16,133,128,128,128,0,33,26,32,0,65,1,54,2,148,10,32,21,32,23,67,154,153,25,62,148,148,67,0,0,0,0,146,32,21,32,26,67,154,153,25,62,148,148,146,33,21,11,2,64,32,0,40,2,180,236,10,34,18,65,127,70,13,0, + 32,21,67,0,0,0,0,146,33,23,2,64,2,64,32,18,65,1,72,13,0,32,0,40,2,176,236,10,33,19,12,1,11,32,4,65,0,65,152,226,10,16,129,128,128,128,0,26,2,64,2,64,65,0,43,3,128,128,128,128,0,68,0,0,0,0,0,0,34,64,163, + 68,0,0,0,64,10,215,15,64,162,34,17,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,17,170,33,19,12,1,11,65,128,128,128,128,120,33,19,11,32,0,32,19,54,2,176,236,10,11,32,0,32,4,32,0,40,2,168,236,10,34,18,32,19, + 107,65,196,216,2,111,34,19,65,196,216,2,106,32,19,32,19,65,0,72,27,65,2,116,106,42,2,0,34,22,56,2,172,236,10,32,0,32,18,65,1,106,65,196,216,2,111,34,19,65,196,216,2,106,32,19,32,19,65,0,72,27,54,2,168, + 236,10,32,4,32,18,65,2,116,106,32,23,32,22,67,51,51,179,62,148,146,56,2,0,32,0,65,1,54,2,180,236,10,32,22,67,0,0,0,0,146,33,22,11,32,1,32,0,40,2,0,65,2,116,106,32,15,67,0,0,0,0,146,32,21,146,32,22,146, + 56,2,0,32,0,32,0,40,2,0,65,1,106,34,18,54,2,0,32,18,32,2,71,13,0,11,11,32,0,65,0,54,2,0,32,3,65,16,106,36,128,128,128,128,0,11,179,3,1,5,127,2,64,2,64,32,0,188,34,1,65,23,118,65,255,1,113,34,2,65,255,1, + 71,13,0,32,0,32,0,149,33,0,12,1,11,2,64,32,1,65,255,255,255,255,7,113,34,3,65,128,128,128,252,3,75,13,0,32,0,67,0,0,0,0,148,32,0,32,3,65,128,128,128,252,3,70,27,33,0,12,1,11,2,64,2,64,32,2,13,0,65,0,33, + 2,2,64,32,1,65,9,116,34,3,65,0,72,13,0,65,0,33,2,3,64,32,2,65,127,106,33,2,32,3,65,1,116,34,3,65,127,74,13,0,11,11,32,1,65,1,32,2,107,116,33,3,12,1,11,32,1,65,255,255,255,3,113,65,128,128,128,4,114,33, + 3,11,2,64,2,64,2,64,32,2,65,128,1,72,13,0,32,2,65,1,106,33,4,3,64,2,64,32,3,65,128,128,128,124,106,34,2,65,0,72,13,0,32,2,33,3,32,2,69,13,3,11,32,3,65,1,116,33,3,32,4,65,127,106,34,4,65,128,1,74,13,0,11, + 65,255,0,33,2,11,2,64,2,64,2,64,2,64,32,3,65,128,128,128,124,106,34,4,65,0,72,13,0,32,4,33,3,32,4,69,13,1,11,32,3,65,255,255,255,3,77,13,1,32,3,33,5,12,2,11,32,0,67,0,0,0,0,148,33,0,12,4,11,3,64,32,2,65, + 127,106,33,2,32,3,65,128,128,128,2,73,33,4,32,3,65,1,116,34,5,33,3,32,4,13,0,11,11,32,1,65,128,128,128,128,120,113,33,3,2,64,32,2,65,1,72,13,0,32,5,65,128,128,128,124,106,32,2,65,23,116,114,33,2,12,2,11, + 32,5,65,1,32,2,107,117,33,2,12,1,11,32,0,67,0,0,0,0,148,33,0,12,1,11,32,2,32,3,114,190,33,0,11,32,0,67,0,0,128,63,146,32,0,32,0,67,0,0,0,0,93,27,11,231,9,4,1,127,2,125,2,127,4,124,35,128,128,128,128,0, + 65,16,107,34,2,36,128,128,128,128,0,2,64,2,64,67,0,0,0,63,67,0,18,3,58,67,0,0,128,63,32,1,67,111,18,131,58,151,147,67,0,0,0,63,148,32,1,67,119,190,127,63,94,27,34,1,147,34,3,32,1,149,32,0,148,34,4,67,0, + 0,128,63,32,0,147,32,3,67,0,0,128,63,32,1,147,149,148,34,1,32,4,32,1,93,27,32,0,146,67,219,15,201,64,148,34,0,188,34,5,65,255,255,255,255,7,113,34,6,65,218,159,164,250,3,75,13,0,67,0,0,128,63,32,0,187, + 34,7,32,7,162,34,7,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,7,32,7,162,34,8,68,66,58,5,225,83,85,165,63,162,160,32,7,32,8,162,32,7,68,105,80,238,224,66,147,249,62,162,68,39,30, + 15,232,135,192,86,191,160,162,160,182,32,6,65,128,128,128,204,3,73,27,33,0,12,1,11,2,64,32,6,65,209,167,237,131,4,75,13,0,32,0,187,33,7,2,64,32,6,65,227,151,219,128,4,77,13,0,68,24,45,68,84,251,33,9,64, + 68,24,45,68,84,251,33,9,192,32,5,65,0,72,27,32,7,160,34,7,32,7,162,34,7,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,7,32,7,162,34,8,68,66,58,5,225,83,85,165,63,162,160,32,7,32,8,162, + 32,7,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,140,33,0,12,2,11,32,7,68,24,45,68,84,251,33,249,63,160,34,9,32,9,32,9,162,34,8,162,34,10,32,8,32,8,162,162,32,8,68, + 167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,9,32,10,32,8,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,68,24,45,68,84,251,33,249,63, + 32,7,161,34,8,32,8,32,8,162,34,7,162,34,9,32,7,32,7,162,162,32,7,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,8,32,9,32,7,68,178,251,110,137,16,17,129,63,162,68,119,172, + 203,84,85,85,197,191,160,162,160,160,32,5,65,0,72,27,182,33,0,12,1,11,2,64,2,64,2,64,32,6,65,214,227,136,135,4,73,13,0,32,6,65,255,255,255,251,7,77,13,1,32,0,32,0,147,33,0,12,3,11,32,6,65,223,219,191,133, + 4,75,13,1,68,210,33,51,127,124,217,18,192,32,0,187,34,9,161,34,8,32,8,32,8,162,34,7,162,34,10,32,7,32,7,162,162,32,7,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,8,32, + 10,32,7,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,32,9,68,210,33,51,127,124,217,18,192,160,34,8,32,8,32,8,162,34,7,162,34,9,32,7,32,7,162,162,32,7,68,167,70,59, + 140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,8,32,9,32,7,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,32,5,65,0,72,27,182,33,0,12,2,11,32,2,32, + 0,16,134,128,128,128,0,32,2,43,3,8,33,7,2,64,2,64,2,64,2,64,32,2,40,2,0,14,3,0,1,2,3,11,32,7,32,7,162,34,7,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,7,32,7,162,34,8,68,66,58,5,225, + 83,85,165,63,162,160,32,7,32,8,162,32,7,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182,33,0,12,4,11,32,7,32,7,162,34,8,32,7,154,162,34,9,32,8,32,8,162,162,32,8,68,167, + 70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,9,32,8,68,178,251,110,137,16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,32,7,161,160,182,33,0,12,3,11,32,7,32,7,162,34, + 7,68,129,94,12,253,255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,7,32,7,162,34,8,68,66,58,5,225,83,85,165,63,162,160,32,7,32,8,162,32,7,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86, + 191,160,162,160,182,140,33,0,12,2,11,32,7,32,7,32,7,162,34,8,162,34,9,32,8,32,8,162,162,32,8,68,167,70,59,140,135,205,198,62,162,68,116,231,202,226,249,0,42,191,160,162,32,7,32,9,32,8,68,178,251,110,137, + 16,17,129,63,162,68,119,172,203,84,85,85,197,191,160,162,160,160,182,33,0,12,1,11,68,24,45,68,84,251,33,25,64,68,24,45,68,84,251,33,25,192,32,5,65,0,72,27,32,0,187,160,34,7,32,7,162,34,7,68,129,94,12,253, + 255,255,223,191,162,68,0,0,0,0,0,0,240,63,160,32,7,32,7,162,34,8,68,66,58,5,225,83,85,165,63,162,160,32,7,32,8,162,32,7,68,105,80,238,224,66,147,249,62,162,68,39,30,15,232,135,192,86,191,160,162,160,182, + 33,0,11,32,2,65,16,106,36,128,128,128,128,0,32,0,140,11,196,19,5,3,127,3,124,7,127,1,124,9,127,35,128,128,128,128,0,65,144,6,107,34,2,36,128,128,128,128,0,2,64,2,64,32,1,188,34,3,65,255,255,255,255,7,113, + 34,4,65,219,159,164,238,4,79,13,0,32,1,187,34,5,68,131,200,201,109,48,95,228,63,162,68,0,0,0,0,0,0,56,67,160,68,0,0,0,0,0,0,56,195,160,34,6,68,99,98,26,97,180,16,81,190,162,33,7,32,5,32,6,68,0,0,0,80,251, + 33,249,191,162,160,33,5,2,64,2,64,32,6,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,6,170,33,4,12,1,11,65,128,128,128,128,120,33,4,11,32,5,32,7,160,33,6,32,4,65,3,113,33,4,12,1,11,2,64,32,4,65,128,128,128,252, + 7,73,13,0,32,1,32,1,147,187,33,6,65,0,33,4,12,1,11,32,2,65,136,4,106,65,144,128,128,128,0,65,136,2,16,128,128,128,128,0,26,32,2,66,128,128,128,128,208,227,252,180,53,55,3,128,4,32,2,66,128,128,128,128, + 168,196,224,241,54,55,3,248,3,32,2,66,128,128,128,128,132,164,137,189,56,55,3,240,3,32,2,66,128,128,128,128,184,240,134,248,57,55,3,232,3,32,2,66,128,128,128,128,150,138,179,188,59,55,3,224,3,32,2,66,128, + 128,128,128,136,211,145,252,60,55,3,216,3,32,2,66,128,128,128,128,208,133,145,186,62,55,3,208,3,32,2,66,128,128,128,128,180,191,200,252,63,55,3,200,3,32,2,65,200,2,106,65,0,65,128,1,16,129,128,128,128, + 0,26,32,2,65,200,1,106,65,0,65,128,1,16,129,128,128,128,0,26,32,2,65,200,0,106,65,0,65,128,1,16,129,128,128,128,0,26,32,2,65,8,106,65,8,106,66,0,55,3,0,32,2,65,24,106,66,0,55,3,0,32,2,65,8,106,65,24,106, + 66,0,55,3,0,32,2,65,40,106,66,0,55,3,0,32,2,65,48,106,66,0,55,3,0,32,2,65,8,106,65,48,106,66,0,55,3,0,32,2,65,192,0,106,66,0,55,3,0,32,2,66,0,55,3,8,32,2,32,2,65,136,4,106,32,4,65,23,118,34,8,65,231,0, + 106,65,255,1,113,65,24,110,34,9,65,2,116,106,34,10,40,2,0,183,34,6,57,3,200,2,32,2,32,6,32,4,32,8,65,234,126,106,34,8,65,23,116,107,190,187,34,7,162,68,0,0,0,0,0,0,0,0,160,57,3,72,32,2,32,10,65,4,106,40, + 2,0,183,34,6,57,3,208,2,32,2,32,6,32,7,162,68,0,0,0,0,0,0,0,0,160,57,3,80,32,2,32,10,65,8,106,40,2,0,183,34,6,57,3,216,2,32,2,32,6,32,7,162,68,0,0,0,0,0,0,0,0,160,57,3,88,32,2,32,10,65,12,106,40,2,0,183, + 34,6,57,3,224,2,32,2,32,6,32,7,162,68,0,0,0,0,0,0,0,0,160,57,3,96,65,47,32,9,65,104,108,32,8,106,34,11,107,33,12,65,48,32,11,107,33,13,32,11,65,104,106,33,14,32,11,65,231,7,106,173,66,52,134,191,33,15, + 32,11,65,25,72,33,16,32,11,65,103,106,33,17,65,3,33,18,2,64,3,64,32,2,65,200,0,106,32,18,65,15,113,34,19,65,3,116,106,43,3,0,33,6,2,64,32,18,65,1,72,34,20,13,0,32,18,65,15,106,33,4,65,0,33,10,3,64,32,10, + 65,15,113,65,2,116,33,8,2,64,2,64,32,6,68,0,0,0,0,0,0,112,62,162,34,5,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,5,170,33,21,12,1,11,65,128,128,128,128,120,33,21,11,32,2,65,8,106,32,8,106,33,8,2,64,2,64,32, + 6,32,21,183,34,5,68,0,0,0,0,0,0,112,193,162,160,34,6,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,6,170,33,21,12,1,11,65,128,128,128,128,120,33,21,11,32,8,32,21,54,2,0,32,2,65,200,0,106,32,4,65,15,113,65,3, + 116,106,43,3,0,32,5,160,33,6,32,10,65,1,106,33,10,32,4,65,127,106,34,4,65,15,71,13,0,11,11,2,64,2,64,32,6,32,15,162,34,6,32,6,68,0,0,0,0,0,0,192,63,162,156,68,0,0,0,0,0,0,32,192,162,160,34,6,153,68,0,0, + 0,0,0,0,224,65,99,69,13,0,32,6,170,33,22,12,1,11,65,128,128,128,128,120,33,22,11,32,6,32,22,183,161,33,6,2,64,2,64,2,64,2,64,32,16,13,0,32,2,65,8,106,32,18,65,127,106,65,15,113,65,2,116,106,34,4,32,4,40, + 2,0,34,4,32,4,32,13,117,34,4,32,13,116,107,34,10,54,2,0,32,10,32,12,117,33,23,32,4,32,22,106,33,22,12,1,11,2,64,32,14,13,0,32,2,65,8,106,32,18,65,127,106,65,15,113,65,2,116,106,40,2,0,65,23,117,33,23,12, + 1,11,65,2,33,23,65,0,33,24,32,6,68,0,0,0,0,0,0,224,63,102,69,13,2,12,1,11,32,23,65,0,74,13,0,32,23,33,24,12,1,11,2,64,2,64,32,20,69,13,0,65,0,33,21,12,1,11,65,0,33,4,65,0,33,21,3,64,32,2,65,8,106,32,4, + 65,15,113,65,2,116,106,34,20,40,2,0,33,10,65,255,255,255,7,33,8,2,64,2,64,32,21,65,1,113,13,0,65,128,128,128,8,33,8,32,10,13,0,65,0,33,21,12,1,11,32,20,32,8,32,10,107,54,2,0,65,1,33,21,11,32,18,32,4,65, + 1,106,34,4,71,13,0,11,11,2,64,32,16,13,0,65,255,255,255,3,33,4,2,64,2,64,32,17,14,2,1,0,2,11,65,255,255,255,1,33,4,11,32,2,65,8,106,32,18,65,127,106,65,15,113,65,2,116,106,34,10,32,10,40,2,0,32,4,113,54, + 2,0,11,32,22,65,1,106,33,22,2,64,32,23,65,2,70,13,0,32,23,33,24,12,1,11,68,0,0,0,0,0,0,240,63,32,6,161,32,15,68,0,0,0,0,0,0,0,0,32,21,27,161,33,6,65,2,33,24,11,2,64,32,6,68,0,0,0,0,0,0,0,0,98,13,0,2,64, + 32,18,65,127,106,34,4,65,3,72,13,0,65,0,33,10,3,64,32,2,65,8,106,32,4,65,15,113,65,2,116,106,40,2,0,32,10,114,33,10,32,4,65,127,106,34,4,65,2,75,13,0,11,32,10,69,13,0,32,14,33,11,3,64,32,11,65,104,106, + 33,11,32,2,65,8,106,32,18,65,127,106,34,18,65,15,113,65,2,116,106,40,2,0,69,13,0,12,4,11,11,65,0,33,4,3,64,32,4,65,2,106,33,10,32,4,65,127,106,34,8,33,4,32,2,65,8,106,32,10,65,15,113,65,2,116,106,40,2, + 0,69,13,0,11,32,18,65,1,106,33,4,32,18,32,8,107,34,21,33,18,32,4,32,21,74,13,1,3,64,32,2,65,200,2,106,32,4,65,3,116,65,248,0,113,34,8,106,32,2,65,136,4,106,32,9,32,4,106,65,194,0,111,34,10,65,194,0,106, + 32,10,32,10,65,0,72,27,65,2,116,106,40,2,0,183,34,6,57,3,0,32,2,65,200,0,106,32,8,106,32,7,32,6,162,68,0,0,0,0,0,0,0,0,160,57,3,0,32,4,65,1,106,34,4,32,21,76,13,0,11,32,21,33,18,12,1,11,11,2,64,2,64,32, + 6,65,151,8,32,11,107,173,66,52,134,191,162,34,6,68,0,0,0,0,0,0,112,65,102,69,13,0,32,19,65,2,116,33,10,2,64,2,64,32,6,68,0,0,0,0,0,0,112,62,162,34,5,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,5,170,33,4,12, + 1,11,65,128,128,128,128,120,33,4,11,32,2,65,8,106,32,10,106,33,10,2,64,2,64,32,6,32,4,183,68,0,0,0,0,0,0,112,193,162,160,34,6,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,6,170,33,8,12,1,11,65,128,128,128,128, + 120,33,8,11,32,10,32,8,54,2,0,32,18,65,1,106,34,18,65,15,113,33,19,12,1,11,2,64,2,64,32,6,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,6,170,33,4,12,1,11,65,128,128,128,128,120,33,4,11,32,14,33,11,11,32,2,65, + 8,106,32,19,65,2,116,106,32,4,54,2,0,11,2,64,2,64,32,11,65,128,8,72,13,0,68,0,0,0,0,0,0,224,127,33,6,32,11,65,129,120,106,34,4,65,255,7,77,13,1,32,11,65,253,23,32,11,65,253,23,72,27,65,130,112,106,33,4, + 68,0,0,0,0,0,0,240,127,33,6,12,1,11,68,0,0,0,0,0,0,240,63,33,6,2,64,32,11,65,130,120,72,13,0,32,11,33,4,12,1,11,2,64,32,11,65,184,112,77,13,0,32,11,65,201,7,106,33,4,68,0,0,0,0,0,0,96,3,33,6,12,1,11,32, + 11,65,240,104,32,11,65,240,104,74,27,65,146,15,106,33,4,68,0,0,0,0,0,0,0,0,33,6,11,2,64,2,64,32,18,65,0,78,13,0,68,0,0,0,0,0,0,0,0,33,6,12,1,11,32,6,32,4,65,255,7,106,173,66,52,134,191,162,33,6,32,18,33, + 4,3,64,32,2,65,200,0,106,32,4,65,15,113,34,10,65,3,116,106,32,6,32,2,65,8,106,32,10,65,2,116,106,40,2,0,183,162,57,3,0,32,6,68,0,0,0,0,0,0,112,62,162,33,6,32,4,65,127,106,34,4,65,127,71,13,0,11,65,0,33, + 20,32,18,33,8,3,64,32,18,32,8,107,33,9,32,20,65,3,32,20,65,3,73,27,65,1,106,33,21,68,0,0,0,0,0,0,0,0,33,6,32,2,65,200,3,106,33,4,65,0,33,10,3,64,32,6,32,4,43,3,0,32,2,65,200,0,106,32,8,32,10,106,65,15, + 113,65,3,116,106,43,3,0,162,160,33,6,32,4,65,8,106,33,4,32,21,32,10,65,1,106,34,10,71,13,0,11,32,2,65,200,1,106,32,9,65,15,113,65,3,116,106,32,6,57,3,0,32,8,65,127,106,33,8,32,20,32,18,71,33,4,32,20,65, + 1,106,33,20,32,4,13,0,11,68,0,0,0,0,0,0,0,0,33,6,3,64,32,6,32,2,65,200,1,106,32,18,65,15,113,65,3,116,106,43,3,0,160,33,6,32,18,65,127,106,34,18,65,127,71,13,0,11,11,32,6,154,32,6,32,24,27,33,6,2,64,32, + 3,65,127,74,13,0,65,0,32,22,107,65,3,113,33,4,32,6,154,33,6,12,1,11,32,22,65,3,113,33,4,11,32,0,32,6,57,3,8,32,0,32,4,54,2,0,32,2,65,144,6,106,36,128,128,128,128,0,11,228,4,3,2,127,3,125,2,127,2,64,32, + 0,188,34,1,65,255,255,255,255,7,113,34,2,13,0,67,0,0,128,63,15,11,2,64,32,2,65,129,128,128,252,7,73,13,0,32,0,67,0,0,0,64,146,15,11,2,64,2,64,32,2,65,128,128,128,252,3,70,13,0,32,2,65,128,128,128,252,7, + 71,13,1,32,0,67,0,0,0,0,32,1,65,127,74,27,15,11,67,0,0,0,64,67,0,0,0,63,32,1,65,127,74,27,15,11,2,64,2,64,32,1,65,128,128,128,248,3,70,13,0,67,0,0,128,64,33,3,32,1,65,128,128,128,128,4,70,13,1,2,64,32, + 2,65,129,128,128,232,4,73,13,0,67,0,0,128,127,67,0,0,0,0,32,1,65,0,74,27,15,11,67,0,0,128,127,33,3,32,0,67,0,0,0,0,148,32,0,32,1,65,128,96,113,190,34,4,147,146,34,0,32,4,146,34,5,188,34,2,65,128,128,128, + 152,4,74,13,1,2,64,2,64,2,64,32,2,65,128,128,128,152,4,71,13,0,65,128,128,128,152,4,33,1,32,0,67,60,170,56,51,146,32,5,32,4,147,94,13,4,12,1,11,67,0,0,0,0,33,3,32,2,65,255,255,255,255,7,113,34,1,65,128, + 128,216,152,4,75,13,3,2,64,32,2,65,128,128,216,152,124,71,13,0,32,0,32,5,32,4,147,95,13,4,11,65,0,33,6,32,1,65,128,128,128,248,3,77,13,1,11,65,0,65,128,128,128,4,32,1,65,23,118,65,130,127,106,118,32,2, + 106,34,1,65,255,255,255,3,113,65,128,128,128,4,114,65,150,1,32,1,65,23,118,65,255,1,113,34,7,107,118,34,6,107,32,6,32,2,65,0,72,27,33,6,32,0,32,4,65,128,128,128,124,32,7,65,129,127,106,117,32,1,113,190, + 147,34,4,146,188,33,2,11,2,64,32,6,65,23,116,32,2,65,128,128,126,113,190,34,3,67,0,114,49,63,148,34,5,32,3,67,140,190,191,53,148,32,0,32,3,32,4,147,147,67,24,114,49,63,148,146,34,4,146,34,0,32,0,32,0,32, + 0,32,0,148,34,3,32,3,32,3,32,3,32,3,67,76,187,49,51,148,67,14,234,221,181,146,148,67,85,179,138,56,146,148,67,97,11,54,187,146,148,67,171,170,42,62,146,148,147,34,3,148,32,3,67,0,0,0,192,146,149,32,4,32, + 0,32,5,147,147,34,3,32,0,32,3,148,146,147,147,67,0,0,128,63,146,34,0,188,106,34,2,65,255,255,255,3,74,13,0,32,0,32,6,16,136,128,128,128,0,15,11,32,2,190,15,11,67,243,4,181,63,33,3,11,32,3,11,164,1,1,1, + 127,2,64,2,64,2,64,32,1,65,128,1,72,13,0,32,0,67,0,0,0,127,148,33,0,32,1,65,129,127,106,34,2,65,255,0,75,13,1,32,2,33,1,12,2,11,32,1,65,130,127,78,13,1,32,0,67,0,0,128,12,148,33,0,2,64,32,1,65,155,126, + 77,13,0,32,1,65,230,0,106,33,1,12,2,11,32,0,67,0,0,128,12,148,33,0,32,1,65,182,125,32,1,65,182,125,74,27,65,204,1,106,33,1,12,1,11,32,0,67,0,0,0,127,148,33,0,32,1,65,253,2,32,1,65,253,2,72,27,65,130,126, + 106,33,1,11,32,0,32,1,65,23,116,65,128,128,128,252,3,106,190,148,11,11,163,138,128,128,0,3,0,65,0,11,8,0,0,0,0,0,0,0,0,0,65,16,11,136,2,131,249,162,0,68,78,110,0,252,41,21,0,209,87,39,0,221,52,245,0,98, + 219,192,0,60,153,149,0,65,144,67,0,99,81,254,0,187,222,171,0,183,97,197,0,58,110,36,0,210,77,66,0,73,6,224,0,9,234,46,0,28,146,209,0,235,29,254,0,41,177,28,0,232,62,167,0,245,53,130,0,68,187,46,0,156,233, + 132,0,180,38,112,0,65,126,95,0,214,145,57,0,83,131,57,0,156,244,57,0,139,95,132,0,40,249,189,0,248,31,59,0,222,255,151,0,15,152,5,0,17,47,239,0,10,90,139,0,109,31,109,0,207,126,54,0,9,203,39,0,70,79,183, + 0,158,102,63,0,45,234,95,0,186,39,117,0,229,235,199,0,61,123,241,0,247,57,7,0,146,82,138,0,251,107,234,0,31,177,95,0,8,93,141,0,48,3,86,0,123,252,70,0,240,171,107,0,32,188,207,0,54,244,154,0,227,169,29, + 0,94,97,145,0,8,27,230,0,133,153,101,0,160,20,95,0,141,64,104,0,128,216,255,0,39,115,77,0,6,6,49,0,202,86,21,0,201,168,115,0,123,226,96,0,107,140,192,0,0,65,160,2,11,128,8,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 206,131,128,128,0,7,108,105,110,107,105,110,103,2,8,240,130,128,128,0,13,0,32,2,10,105,110,105,116,105,97,108,105,115,101,1,2,12,46,76,95,102,114,101,113,117,101,110,99,121,0,0,8,0,32,3,12,97,100,118,97, + 110,99,101,66,108,111,99,107,2,16,0,0,2,4,25,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,119,114,97,112,95,50,0,2,5,23,46,76,82,101,112,108,105,99,97,110,116,95,95,112,100,83,97, + 119,116,111,111,116,104,1,2,15,46,76,95,95,99,111,110,115,116,97,110,116,95,95,52,2,0,128,8,0,16,0,0,2,6,77,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110, + 97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101,110,116,97,116,105,111,110,115,95,95,104,101,108,112,101,114,115,95,95,114,101,109,97,105,110,100,101,114,80,105,79,118,101,114,50,102,0,2,7,54, + 46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101,110,116,97,116,105,111,110,115,95,95,112,111,119, + 0,16,1,0,2,8,67,46,76,115,116,100,95,95,105,110,116,114,105,110,115,105,99,115,95,95,105,110,116,101,114,110,97,108,95,95,109,97,116,104,95,105,109,112,108,101,109,101,110,116,97,116,105,111,110,115,95, + 95,104,101,108,112,101,114,115,95,95,115,99,97,108,98,110,102,1,2,15,46,76,95,95,99,111,110,115,116,97,110,116,95,95,51,1,0,136,2,5,201,128,128,128,0,3,17,46,98,115,115,46,46,76,95,102,114,101,113,117, + 101,110,99,121,3,0,23,46,114,111,100,97,116,97,46,46,76,95,95,99,111,110,115,116,97,110,116,95,95,51,4,0,23,46,114,111,100,97,116,97,46,46,76,95,95,99,111,110,115,116,97,110,116,95,95,52,4,0,0,180,129, + 128,128,0,10,114,101,108,111,99,46,67,79,68,69,5,40,3,9,1,0,7,39,3,7,50,3,3,179,2,1,0,3,158,10,1,0,0,134,11,4,0,158,11,4,0,214,11,4,0,242,11,4,0,132,12,5,4,177,13,6,0,0,186,13,7,3,238,14,1,0,3,197,15,1, + 0,0,221,18,4,0,208,23,8,0,132,28,9,3,154,28,1,0,0,204,28,9,3,226,28,1,0,0,135,29,4,0,154,29,5,0,177,29,4,0,196,29,5,0,173,30,10,3,187,30,1,0,7,188,32,3,7,131,36,3,7,142,36,3,0,141,42,8,7,215,45,3,7,238, + 45,3,7,250,45,3,4,181,47,12,0,0,190,47,7,0,208,48,10,0,226,48,10,0,244,48,10,7,160,65,3,0,245,69,11,]); + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/assets/cmajor-logo.svg b/assets/example_patches/Replicant/cmaj_api/assets/cmajor-logo.svg new file mode 100644 index 00000000..70685d54 --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/assets/cmajor-logo.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-event-listener-list.js b/assets/example_patches/Replicant/cmaj_api/cmaj-event-listener-list.js new file mode 100644 index 00000000..0c13ea9e --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-event-listener-list.js @@ -0,0 +1,112 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +/** This event listener management class allows listeners to be attached and + * removed from named event types. + */ +export class EventListenerList +{ + constructor() + { + this.listenersPerType = {}; + } + + /** Adds a listener for a specifc event type. + * If the listener is already registered, this will simply add it again. + * Each call to addEventListener() must be paired with a removeventListener() + * call to remove it. + * + * @param {string} type + */ + addEventListener (type, listener) + { + if (type && listener) + { + const list = this.listenersPerType[type]; + + if (list) + list.push (listener); + else + this.listenersPerType[type] = [listener]; + } + } + + /** Removes a listener that was previously added for the given event type. + * @param {string} type + */ + removeEventListener (type, listener) + { + if (type && listener) + { + const list = this.listenersPerType[type]; + + if (list) + { + const i = list.indexOf (listener); + + if (i >= 0) + list.splice (i, 1); + } + } + } + + /** Attaches a callback function that will be automatically unregistered + * the first time it is invoked. + * + * @param {string} type + */ + addSingleUseListener (type, listener) + { + const l = message => + { + this.removeEventListener (type, l); + listener?.(message); + }; + + this.addEventListener (type, l); + } + + /** Synchronously dispatches an event object to all listeners + * that are registered for the given type. + * + * @param {string} type + */ + dispatchEvent (type, event) + { + const list = this.listenersPerType[type]; + + if (list) + for (const listener of list) + listener?.(event); + } + + /** Returns the number of listeners that are currently registered + * for the given type of event. + * + * @param {string} type + */ + getNumListenersForType (type) + { + const list = this.listenersPerType[type]; + return list ? list.length : 0; + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-generic-patch-view.js b/assets/example_patches/Replicant/cmaj_api/cmaj-generic-patch-view.js new file mode 100644 index 00000000..0370dc96 --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-generic-patch-view.js @@ -0,0 +1,186 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import * as Controls from "./cmaj-parameter-controls.js" + +//============================================================================== +/** A simple, generic view which can control any type of patch */ +class GenericPatchView extends HTMLElement +{ + /** Creates a view for a patch. + * @param {PatchConnection} patchConnection - the connection to the target patch + */ + constructor (patchConnection) + { + super(); + + this.patchConnection = patchConnection; + + this.statusListener = status => + { + this.status = status; + this.createControlElements(); + }; + + this.attachShadow ({ mode: "open" }); + this.shadowRoot.innerHTML = this.getHTML(); + + this.titleElement = this.shadowRoot.getElementById ("patch-title"); + this.parametersElement = this.shadowRoot.getElementById ("patch-parameters"); + } + + /** This is picked up by some of our wrapper code to know whether it makes + * sense to put a title bar/logo above the GUI. + */ + hasOwnTitleBar() + { + return true; + } + + //============================================================================== + /** @private */ + connectedCallback() + { + this.patchConnection.addStatusListener (this.statusListener); + this.patchConnection.requestStatusUpdate(); + } + + /** @private */ + disconnectedCallback() + { + this.patchConnection.removeStatusListener (this.statusListener); + } + + /** @private */ + createControlElements() + { + this.parametersElement.innerHTML = ""; + this.titleElement.innerText = this.status?.manifest?.name ?? "Cmajor"; + + for (const endpointInfo of this.status?.details?.inputs) + { + if (! endpointInfo.annotation?.hidden) + { + const control = Controls.createLabelledControl (this.patchConnection, endpointInfo); + + if (control) + this.parametersElement.appendChild (control); + } + } + } + + /** @private */ + getHTML() + { + return ` + + +
+
+ +

+
+
+
+
`; + } +} + +window.customElements.define ("cmaj-generic-patch-view", GenericPatchView); + +//============================================================================== +/** Creates a generic view element which can be used to control any patch. + * @param {PatchConnection} patchConnection - the connection to the target patch + */ +export default function createPatchView (patchConnection) +{ + return new GenericPatchView (patchConnection); +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-midi-helpers.js b/assets/example_patches/Replicant/cmaj_api/cmaj-midi-helpers.js new file mode 100644 index 00000000..1cc4933b --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-midi-helpers.js @@ -0,0 +1,181 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +export function getByte0 (message) { return (message >> 16) & 0xff; } +export function getByte1 (message) { return (message >> 8) & 0xff; } +export function getByte2 (message) { return message & 0xff; } + +function isVoiceMessage (message, type) { return ((message >> 16) & 0xf0) == type; } +function get14BitValue (message) { return getByte1 (message) | (getByte2 (message) << 7); } + +export function getChannel0to15 (message) { return getByte0 (message) & 0x0f; } +export function getChannel1to16 (message) { return getChannel0to15 (message) + 1; } + +export function getMessageSize (message) +{ + const mainGroupLengths = (3 << 0) | (3 << 2) | (3 << 4) | (3 << 6) + | (2 << 8) | (2 << 10) | (3 << 12); + + const lastGroupLengths = (1 << 0) | (2 << 2) | (3 << 4) | (2 << 6) + | (1 << 8) | (1 << 10) | (1 << 12) | (1 << 14) + | (1 << 16) | (1 << 18) | (1 << 20) | (1 << 22) + | (1 << 24) | (1 << 26) | (1 << 28) | (1 << 30); + + const firstByte = getByte0 (message); + const group = (firstByte >> 4) & 7; + + return (group != 7 ? (mainGroupLengths >> (2 * group)) + : (lastGroupLengths >> (2 * (firstByte & 15)))) & 3; +} + +export function isNoteOn (message) { return isVoiceMessage (message, 0x90) && getVelocity (message) != 0; } +export function isNoteOff (message) { return isVoiceMessage (message, 0x80) || (isVoiceMessage (message, 0x90) && getVelocity (message) == 0); } + +export function getNoteNumber (message) { return getByte1 (message); } +export function getVelocity (message) { return getByte2 (message); } + +export function isProgramChange (message) { return isVoiceMessage (message, 0xc0); } +export function getProgramChangeNumber (message) { return getByte1 (message); } +export function isPitchWheel (message) { return isVoiceMessage (message, 0xe0); } +export function getPitchWheelValue (message) { return get14BitValue (message); } +export function isAftertouch (message) { return isVoiceMessage (message, 0xa0); } +export function getAfterTouchValue (message) { return getByte2 (message); } +export function isChannelPressure (message) { return isVoiceMessage (message, 0xd0); } +export function getChannelPressureValue (message) { return getByte1 (message); } +export function isController (message) { return isVoiceMessage (message, 0xb0); } +export function getControllerNumber (message) { return getByte1 (message); } +export function getControllerValue (message) { return getByte2 (message); } +export function isControllerNumber (message, number) { return getByte1 (message) == number && isController (message); } +export function isAllNotesOff (message) { return isControllerNumber (message, 123); } +export function isAllSoundOff (message) { return isControllerNumber (message, 120); } +export function isQuarterFrame (message) { return getByte0 (message) == 0xf1; } +export function isClock (message) { return getByte0 (message) == 0xf8; } +export function isStart (message) { return getByte0 (message) == 0xfa; } +export function isContinue (message) { return getByte0 (message) == 0xfb; } +export function isStop (message) { return getByte0 (message) == 0xfc; } +export function isActiveSense (message) { return getByte0 (message) == 0xfe; } +export function isMetaEvent (message) { return getByte0 (message) == 0xff; } +export function isSongPositionPointer (message) { return getByte0 (message) == 0xf2; } +export function getSongPositionPointerValue (message) { return get14BitValue (message); } + +export function getChromaticScaleIndex (note) { return (note % 12) & 0xf; } +export function getOctaveNumber (note, octaveForMiddleC) { return ((Math.floor (note / 12) + (octaveForMiddleC ? octaveForMiddleC : 3)) & 0xff) - 5; } +export function getNoteName (note) { const names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithSharps (note) { const names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithFlats (note) { const names = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"]; return names[getChromaticScaleIndex (note)]; } +export function getNoteNameWithOctaveNumber (note) { return getNoteName (note) + getOctaveNumber (note); } +export function isNatural (note) { const nats = [true, false, true, false, true, true, false, true, false, true, false, true]; return nats[getChromaticScaleIndex (note)]; } +export function isAccidental (note) { return ! isNatural (note); } + +export function printHexMIDIData (message) +{ + const numBytes = getMessageSize (message); + + if (numBytes == 0) + return "[empty]"; + + let s = ""; + + for (let i = 0; i < numBytes; ++i) + { + if (i != 0) s += ' '; + + const byte = message >> (16 - 8 * i) & 0xff; + s += "0123456789abcdef"[byte >> 4]; + s += "0123456789abcdef"[byte & 15]; + } + + return s; +} + +export function getMIDIDescription (message) +{ + const channelText = " Channel " + getChannel1to16 (message); + function getNote (m) { const s = getNoteNameWithOctaveNumber (getNoteNumber (message)); return s.length < 4 ? s + " " : s; }; + + if (isNoteOn (message)) return "Note-On: " + getNote (message) + channelText + " Velocity " + getVelocity (message); + if (isNoteOff (message)) return "Note-Off: " + getNote (message) + channelText + " Velocity " + getVelocity (message); + if (isAftertouch (message)) return "Aftertouch: " + getNote (message) + channelText + ": " + getAfterTouchValue (message); + if (isPitchWheel (message)) return "Pitch wheel: " + getPitchWheelValue (message) + ' ' + channelText; + if (isChannelPressure (message)) return "Channel pressure: " + getChannelPressureValue (message) + ' ' + channelText; + if (isController (message)) return "Controller:" + channelText + ": " + getControllerName (getControllerNumber (message)) + " = " + getControllerValue (message); + if (isProgramChange (message)) return "Program change: " + getProgramChangeNumber (message) + ' ' + channelText; + if (isAllNotesOff (message)) return "All notes off:" + channelText; + if (isAllSoundOff (message)) return "All sound off:" + channelText; + if (isQuarterFrame (message)) return "Quarter-frame"; + if (isClock (message)) return "Clock"; + if (isStart (message)) return "Start"; + if (isContinue (message)) return "Continue"; + if (isStop (message)) return "Stop"; + if (isMetaEvent (message)) return "Meta-event: type " + getByte1 (message); + if (isSongPositionPointer (message)) return "Song Position: " + getSongPositionPointerValue (message); + + return printHexMIDIData (message); +} + +export function getControllerName (controllerNumber) +{ + if (controllerNumber < 128) + { + const controllerNames = [ + "Bank Select", "Modulation Wheel (coarse)", "Breath controller (coarse)", undefined, + "Foot Pedal (coarse)", "Portamento Time (coarse)", "Data Entry (coarse)", "Volume (coarse)", + "Balance (coarse)", undefined, "Pan position (coarse)", "Expression (coarse)", + "Effect Control 1 (coarse)", "Effect Control 2 (coarse)", undefined, undefined, + "General Purpose Slider 1", "General Purpose Slider 2", "General Purpose Slider 3", "General Purpose Slider 4", + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "Bank Select (fine)", "Modulation Wheel (fine)", "Breath controller (fine)", undefined, + "Foot Pedal (fine)", "Portamento Time (fine)", "Data Entry (fine)", "Volume (fine)", + "Balance (fine)", undefined, "Pan position (fine)", "Expression (fine)", + "Effect Control 1 (fine)", "Effect Control 2 (fine)", undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "Hold Pedal", "Portamento", "Sustenuto Pedal", "Soft Pedal", + "Legato Pedal", "Hold 2 Pedal", "Sound Variation", "Sound Timbre", + "Sound Release Time", "Sound Attack Time", "Sound Brightness", "Sound Control 6", + "Sound Control 7", "Sound Control 8", "Sound Control 9", "Sound Control 10", + "General Purpose Button 1", "General Purpose Button 2", "General Purpose Button 3", "General Purpose Button 4", + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, "Reverb Level", + "Tremolo Level", "Chorus Level", "Celeste Level", "Phaser Level", + "Data Button increment", "Data Button decrement", "Non-registered Parameter (fine)", "Non-registered Parameter (coarse)", + "Registered Parameter (fine)", "Registered Parameter (coarse)", undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + "All Sound Off", "All Controllers Off", "Local Keyboard", "All Notes Off", + "Omni Mode Off", "Omni Mode On", "Mono Operation", "Poly Operation" + ]; + + const name = controllerNames[controllerNumber]; + + if (name) + return name; + } + + return controllerNumber.toString(); +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-parameter-controls.js b/assets/example_patches/Replicant/cmaj_api/cmaj-parameter-controls.js new file mode 100644 index 00000000..c6290d05 --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-parameter-controls.js @@ -0,0 +1,844 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js"; + + +//============================================================================== +/** A base class for parameter controls, which automatically connects to a + * PatchConnection to monitor a parameter and provides methods to modify it. + */ +export class ParameterControlBase extends HTMLElement +{ + constructor() + { + super(); + + // prevent any clicks from focusing on this element + this.onmousedown = e => e.stopPropagation(); + } + + /** Attaches the control to a given PatchConnection and endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to, or pass + * undefined to disconnect the control. + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. + */ + setEndpoint (patchConnection, endpointInfo) + { + this.detachListener(); + + this.patchConnection = patchConnection; + this.endpointInfo = endpointInfo; + this.defaultValue = endpointInfo.annotation?.init || endpointInfo.defaultValue || 0; + + if (this.isConnected) + this.attachListener(); + } + + /** Override this method in a child class, and it will be called when the parameter value changes, + * so you can update the GUI appropriately. + */ + valueChanged (newValue) {} + + /** Your GUI can call this when it wants to change the parameter value. */ + setValue (value) { this.patchConnection?.sendEventOrValue (this.endpointInfo.endpointID, value); } + + /** Call this before your GUI begins a modification gesture. + * You might for example call this if the user begins a mouse-drag operation. + */ + beginGesture() { this.patchConnection?.sendParameterGestureStart (this.endpointInfo.endpointID); } + + /** Call this after your GUI finishes a modification gesture */ + endGesture() { this.patchConnection?.sendParameterGestureEnd (this.endpointInfo.endpointID); } + + /** This calls setValue(), but sandwiches it between some start/end gesture calls. + * You should use this to make sure a DAW correctly records automatiion for individual value changes + * that are not part of a gesture. + */ + setValueAsGesture (value) + { + this.beginGesture(); + this.setValue (value); + this.endGesture(); + } + + /** Resets the parameter to its default value */ + resetToDefault() + { + if (this.defaultValue !== null) + this.setValueAsGesture (this.defaultValue); + } + + //============================================================================== + /** @private */ + connectedCallback() + { + this.attachListener(); + } + + /** @protected */ + disconnectedCallback() + { + this.detachListener(); + } + + /** @private */ + detachListener() + { + if (this.listener) + { + this.patchConnection?.removeParameterListener?.(this.listener.endpointID, this.listener); + this.listener = undefined; + } + } + + /** @private */ + attachListener() + { + if (this.patchConnection && this.endpointInfo) + { + this.detachListener(); + + this.listener = newValue => this.valueChanged (newValue); + this.listener.endpointID = this.endpointInfo.endpointID; + + this.patchConnection.addParameterListener (this.endpointInfo.endpointID, this.listener); + this.patchConnection.requestParameterValue (this.endpointInfo.endpointID); + } + } +} + +//============================================================================== +/** A simple rotary parameter knob control. */ +export class Knob extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + this.innerHTML = ""; + this.className = "knob-container"; + const min = endpointInfo?.annotation?.min || 0; + const max = endpointInfo?.annotation?.max || 1; + + const createSvgElement = tag => window.document.createElementNS ("http://www.w3.org/2000/svg", tag); + + const svg = createSvgElement ("svg"); + svg.setAttribute ("viewBox", "0 0 100 100"); + + const trackBackground = createSvgElement ("path"); + trackBackground.setAttribute ("d", "M20,76 A 40 40 0 1 1 80 76"); + trackBackground.classList.add ("knob-path"); + trackBackground.classList.add ("knob-track-background"); + + const maxKnobRotation = 132; + const isBipolar = min + max === 0; + const dashLength = isBipolar ? 251.5 : 184; + const valueOffset = isBipolar ? 0 : 132; + this.getDashOffset = val => dashLength - 184 / (maxKnobRotation * 2) * (val + valueOffset); + + this.trackValue = createSvgElement ("path"); + + this.trackValue.setAttribute ("d", isBipolar ? "M50.01,10 A 40 40 0 1 1 50 10" + : "M20,76 A 40 40 0 1 1 80 76"); + this.trackValue.setAttribute ("stroke-dasharray", dashLength); + this.trackValue.classList.add ("knob-path"); + this.trackValue.classList.add ("knob-track-value"); + + this.dial = document.createElement ("div"); + this.dial.className = "knob-dial"; + + const dialTick = document.createElement ("div"); + dialTick.className = "knob-dial-tick"; + this.dial.appendChild (dialTick); + + svg.appendChild (trackBackground); + svg.appendChild (this.trackValue); + + this.appendChild (svg); + this.appendChild (this.dial); + + const remap = (source, sourceFrom, sourceTo, targetFrom, targetTo) => + (targetFrom + (source - sourceFrom) * (targetTo - targetFrom) / (sourceTo - sourceFrom)); + + const toValue = (knobRotation) => remap (knobRotation, -maxKnobRotation, maxKnobRotation, min, max); + this.toRotation = (value) => remap (value, min, max, -maxKnobRotation, maxKnobRotation); + + this.rotation = this.toRotation (this.defaultValue); + this.setRotation (this.rotation, true); + + const onMouseMove = (event) => + { + event.preventDefault(); // avoid scrolling whilst dragging + + const nextRotation = (rotation, delta) => + { + const clamp = (v, min, max) => Math.min (Math.max (v, min), max); + return clamp (rotation - delta, -maxKnobRotation, maxKnobRotation); + }; + + const workaroundBrowserIncorrectlyCalculatingMovementY = event.movementY === event.screenY; + const movementY = workaroundBrowserIncorrectlyCalculatingMovementY ? event.screenY - this.previousScreenY + : event.movementY; + this.previousScreenY = event.screenY; + + const speedMultiplier = event.shiftKey ? 0.25 : 1.5; + this.accumulatedRotation = nextRotation (this.accumulatedRotation, movementY * speedMultiplier); + this.setValue (toValue (this.accumulatedRotation)); + }; + + const onMouseUp = (event) => + { + this.previousScreenY = undefined; + this.accumulatedRotation = undefined; + window.removeEventListener ("mousemove", onMouseMove); + window.removeEventListener ("mouseup", onMouseUp); + this.endGesture(); + }; + + const onMouseDown = (event) => + { + this.previousScreenY = event.screenY; + this.accumulatedRotation = this.rotation; + this.beginGesture(); + window.addEventListener ("mousemove", onMouseMove); + window.addEventListener ("mouseup", onMouseUp); + event.preventDefault(); + }; + + const onTouchStart = (event) => + { + this.previousClientY = event.changedTouches[0].clientY; + this.accumulatedRotation = this.rotation; + this.touchIdentifier = event.changedTouches[0].identifier; + this.beginGesture(); + window.addEventListener ("touchmove", onTouchMove); + window.addEventListener ("touchend", onTouchEnd); + event.preventDefault(); + }; + + const onTouchMove = (event) => + { + for (const touch of event.changedTouches) + { + if (touch.identifier == this.touchIdentifier) + { + const nextRotation = (rotation, delta) => + { + const clamp = (v, min, max) => Math.min (Math.max (v, min), max); + return clamp (rotation - delta, -maxKnobRotation, maxKnobRotation); + }; + + const movementY = touch.clientY - this.previousClientY; + this.previousClientY = touch.clientY; + + const speedMultiplier = event.shiftKey ? 0.25 : 1.5; + this.accumulatedRotation = nextRotation (this.accumulatedRotation, movementY * speedMultiplier); + this.setValue (toValue (this.accumulatedRotation)); + } + } + }; + + const onTouchEnd = (event) => + { + this.previousClientY = undefined; + this.accumulatedRotation = undefined; + window.removeEventListener ("touchmove", onTouchMove); + window.removeEventListener ("touchend", onTouchEnd); + this.endGesture(); + }; + + this.addEventListener ("mousedown", onMouseDown); + this.addEventListener ("dblclick", () => this.resetToDefault()); + this.addEventListener ('touchstart', onTouchStart); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter"; + } + + /** @override */ + valueChanged (newValue) { this.setRotation (this.toRotation (newValue), false); } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return toFloatDisplayValueWithUnit (v, this.endpointInfo); } + + /** @private */ + setRotation (degrees, force) + { + if (force || this.rotation !== degrees) + { + this.rotation = degrees; + this.trackValue.setAttribute ("stroke-dashoffset", this.getDashOffset (this.rotation)); + this.dial.style.transform = `translate(-50%,-50%) rotate(${degrees}deg)`; + } + } + + /** @private */ + static getCSS() + { + return ` + .knob-container { + --knob-track-background-color: var(--background); + --knob-track-value-color: var(--foreground); + + --knob-dial-border-color: var(--foreground); + --knob-dial-background-color: var(--background); + --knob-dial-tick-color: var(--foreground); + + position: relative; + display: inline-block; + height: 5rem; + width: 5rem; + margin: 0; + padding: 0; + } + + .knob-path { + fill: none; + stroke-linecap: round; + stroke-width: 0.15rem; + } + + .knob-track-background { + stroke: var(--knob-track-background-color); + } + + .knob-track-value { + stroke: var(--knob-track-value-color); + } + + .knob-dial { + position: absolute; + text-align: center; + height: 60%; + width: 60%; + top: 50%; + left: 50%; + border: 0.15rem solid var(--knob-dial-border-color); + border-radius: 100%; + box-sizing: border-box; + transform: translate(-50%,-50%); + background-color: var(--knob-dial-background-color); + } + + .knob-dial-tick { + position: absolute; + display: inline-block; + + height: 1rem; + width: 0.15rem; + background-color: var(--knob-dial-tick-color); + }`; + } +} + +//============================================================================== +/** A boolean switch control */ +export class Switch extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + const outer = document.createElement ("div"); + outer.classList = "switch-outline"; + + const inner = document.createElement ("div"); + inner.classList = "switch-thumb"; + + this.innerHTML = ""; + this.currentValue = this.defaultValue > 0.5; + this.valueChanged (this.currentValue); + this.classList.add ("switch-container"); + + outer.appendChild (inner); + this.appendChild (outer); + this.addEventListener ("click", () => this.setValueAsGesture (this.currentValue ? 0 : 1.0)); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter" + && endpointInfo.annotation?.boolean; + } + + /** @override */ + valueChanged (newValue) + { + const b = newValue > 0.5; + this.currentValue = b; + this.classList.remove (! b ? "switch-on" : "switch-off"); + this.classList.add (b ? "switch-on" : "switch-off"); + } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return `${v > 0.5 ? "On" : "Off"}`; } + + /** @private */ + static getCSS() + { + return ` + .switch-container { + --switch-outline-color: var(--foreground); + --switch-thumb-color: var(--foreground); + --switch-on-background-color: var(--background); + --switch-off-background-color: var(--background); + + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + } + + .switch-outline { + position: relative; + display: inline-block; + height: 1.25rem; + width: 2.5rem; + border-radius: 10rem; + box-shadow: 0 0 0 0.15rem var(--switch-outline-color); + transition: background-color 0.1s cubic-bezier(0.5, 0, 0.2, 1); + } + + .switch-thumb { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + height: 1rem; + width: 1rem; + background-color: var(--switch-thumb-color); + border-radius: 100%; + transition: left 0.1s cubic-bezier(0.5, 0, 0.2, 1); + } + + .switch-off .switch-thumb { + left: 25%; + background: none; + border: var(--switch-thumb-color) solid 0.1rem; + height: 0.8rem; + width: 0.8rem; + } + .switch-on .switch-thumb { + left: 75%; + } + + .switch-off .switch-outline { + background-color: var(--switch-on-background-color); + } + .switch-on .switch-outline { + background-color: var(--switch-off-background-color); + }`; + } +} + +//============================================================================== +function toFloatDisplayValueWithUnit (v, endpointInfo) +{ + return `${v.toFixed (2)} ${endpointInfo.annotation?.unit ?? ""}`; +} + +//============================================================================== +/** A control that allows an item to be selected from a drop-down list of options */ +export class Options extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo) + { + super(); + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + const toValue = (min, step, index) => min + (step * index); + const toStepCount = count => count > 0 ? count - 1 : 1; + + const { min, max, options } = (() => + { + if (Options.hasTextOptions (endpointInfo)) + { + const optionList = endpointInfo.annotation.text.split ("|"); + const stepCount = toStepCount (optionList.length); + let min = 0, max = stepCount, step = 1; + + if (endpointInfo.annotation.min != null && endpointInfo.annotation.max != null) + { + min = endpointInfo.annotation.min; + max = endpointInfo.annotation.max; + step = (max - min) / stepCount; + } + + const options = optionList.map ((text, index) => ({ value: toValue (min, step, index), text })); + + return { min, max, options }; + } + + if (Options.isExplicitlyDiscrete (endpointInfo)) + { + const step = endpointInfo.annotation.step; + + const min = endpointInfo.annotation?.min || 0; + const max = endpointInfo.annotation?.max || 1; + + const numDiscreteOptions = (((max - min) / step) | 0) + 1; + + const options = new Array (numDiscreteOptions); + for (let i = 0; i < numDiscreteOptions; ++i) + { + const value = toValue (min, step, i); + options[i] = { value, text: toFloatDisplayValueWithUnit (value, endpointInfo) }; + } + + return { min, max, options }; + } + })(); + + this.options = options; + + const stepCount = toStepCount (this.options.length); + const normalise = value => (value - min) / (max - min); + this.toIndex = value => Math.min (stepCount, normalise (value) * this.options.length) | 0; + + this.innerHTML = ""; + + this.select = document.createElement ("select"); + + for (const option of this.options) + { + const optionElement = document.createElement ("option"); + optionElement.innerText = option.text; + this.select.appendChild (optionElement); + } + + this.selectedIndex = this.toIndex (this.defaultValue); + + this.select.addEventListener ("change", (e) => + { + const newIndex = e.target.selectedIndex; + + // prevent local state change. the caller will update us when the backend actually applies the update + e.target.selectedIndex = this.selectedIndex; + + this.setValueAsGesture (this.options[newIndex].value) + }); + + this.valueChanged (this.selectedIndex); + + this.className = "select-container"; + this.appendChild (this.select); + + const icon = document.createElement ("span"); + icon.className = "select-icon"; + this.appendChild (icon); + } + + /** Returns true if this type of control is suitable for the given endpoint info */ + static canBeUsedFor (endpointInfo) + { + return endpointInfo.purpose === "parameter" + && (this.hasTextOptions (endpointInfo) || this.isExplicitlyDiscrete (endpointInfo)); + } + + /** @override */ + valueChanged (newValue) + { + const index = this.toIndex (newValue); + this.selectedIndex = index; + this.select.selectedIndex = index; + } + + /** Returns a string version of the given value */ + getDisplayValue (v) { return this.options[this.toIndex(v)].text; } + + /** @private */ + static hasTextOptions (endpointInfo) + { + return endpointInfo.annotation?.text?.split?.("|").length > 1 + } + + /** @private */ + static isExplicitlyDiscrete (endpointInfo) + { + return endpointInfo.annotation?.discrete && endpointInfo.annotation?.step > 0; + } + + /** @private */ + static getCSS() + { + return ` + .select-container { + position: relative; + display: block; + font-size: 0.8rem; + width: 100%; + color: var(--foreground); + border: 0.15rem solid var(--foreground); + border-radius: 0.6rem; + margin: 0; + padding: 0; + } + + select { + background: none; + appearance: none; + -webkit-appearance: none; + font-family: inherit; + font-size: 0.8rem; + + overflow: hidden; + text-overflow: ellipsis; + + padding: 0 1.5rem 0 0.6rem; + + outline: none; + color: var(--foreground); + height: 2rem; + box-sizing: border-box; + margin: 0; + border: none; + + width: 100%; + } + + select option { + background: var(--background); + color: var(--foreground); + } + + .select-icon { + position: absolute; + right: 0.3rem; + top: 0.5rem; + pointer-events: none; + background-color: var(--foreground); + width: 1.4em; + height: 1.4em; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z'/%3E%3C/svg%3E"); + mask-repeat: no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z'/%3E%3C/svg%3E"); + -webkit-mask-repeat: no-repeat; + }`; + } +} + +//============================================================================== +/** A control which wraps a child control, adding a label and value display box below it */ +export class LabelledControlHolder extends ParameterControlBase +{ + constructor (patchConnection, endpointInfo, childControl) + { + super(); + this.childControl = childControl; + this.setEndpoint (patchConnection, endpointInfo); + } + + setEndpoint (patchConnection, endpointInfo) + { + super.setEndpoint (patchConnection, endpointInfo); + + this.innerHTML = ""; + this.className = "labelled-control"; + + const centeredControl = document.createElement ("div"); + centeredControl.className = "labelled-control-centered-control"; + + centeredControl.appendChild (this.childControl); + + const titleValueHoverContainer = document.createElement ("div"); + titleValueHoverContainer.className = "labelled-control-label-container"; + + const nameText = document.createElement ("div"); + nameText.classList.add ("labelled-control-name"); + nameText.innerText = endpointInfo.annotation?.name || endpointInfo.name || endpointInfo.endpointID || ""; + + this.valueText = document.createElement ("div"); + this.valueText.classList.add ("labelled-control-value"); + + titleValueHoverContainer.appendChild (nameText); + titleValueHoverContainer.appendChild (this.valueText); + + this.appendChild (centeredControl); + this.appendChild (titleValueHoverContainer); + } + + /** @override */ + valueChanged (newValue) + { + this.valueText.innerText = this.childControl?.getDisplayValue (newValue); + } + + /** @private */ + static getCSS() + { + return ` + .labelled-control { + --labelled-control-font-color: var(--foreground); + --labelled-control-font-size: 0.8rem; + + position: relative; + display: inline-block; + margin: 0 0.4rem 0.4rem; + vertical-align: top; + text-align: left; + padding: 0; + } + + .labelled-control-centered-control { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + width: 5.5rem; + height: 5rem; + } + + .labelled-control-label-container { + position: relative; + display: block; + max-width: 5.5rem; + margin: -0.4rem auto 0.4rem; + text-align: center; + font-size: var(--labelled-control-font-size); + color: var(--labelled-control-font-color); + cursor: default; + } + + .labelled-control-name { + overflow: hidden; + text-overflow: ellipsis; + } + + .labelled-control-value { + position: absolute; + top: 0; + left: 0; + right: 0; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + } + + .labelled-control:hover .labelled-control-name, + .labelled-control:active .labelled-control-name { + opacity: 0; + } + .labelled-control:hover .labelled-control-value, + .labelled-control:active .labelled-control-value { + opacity: 1; + }`; + } +} + +window.customElements.define ("cmaj-knob-control", Knob); +window.customElements.define ("cmaj-switch-control", Switch); +window.customElements.define ("cmaj-options-control", Options); +window.customElements.define ("cmaj-labelled-control-holder", LabelledControlHolder); + +//============================================================================== +/** Fetches all the CSS for the controls defined in this module */ +export function getAllCSS() +{ + return ` + ${Options.getCSS()} + ${Knob.getCSS()} + ${Switch.getCSS()} + ${LabelledControlHolder.getCSS()}`; +} + +//============================================================================== +/** Creates a suitable control for the given endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. +*/ +export function createControl (patchConnection, endpointInfo) +{ + if (Switch.canBeUsedFor (endpointInfo)) + return new Switch (patchConnection, endpointInfo); + + if (Options.canBeUsedFor (endpointInfo)) + return new Options (patchConnection, endpointInfo); + + if (Knob.canBeUsedFor (endpointInfo)) + return new Knob (patchConnection, endpointInfo); + + return undefined; +} + +//============================================================================== +/** Creates a suitable labelled control for the given endpoint. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} endpointInfo - the endpoint details, as provided by a PatchConnection + * in its status callback. +*/ +export function createLabelledControl (patchConnection, endpointInfo) +{ + const control = createControl (patchConnection, endpointInfo); + + if (control) + return new LabelledControlHolder (patchConnection, endpointInfo, control); + + return undefined; +} + +//============================================================================== +/** Takes a patch connection and its current status object, and tries to create + * a control for the given endpoint ID. + * + * @param {PatchConnection} patchConnection - the connection to connect to + * @param {Object} status - the connection's current status + * @param {string} endpointID - the endpoint you'd like to control + */ +export function createLabelledControlForEndpointID (patchConnection, status, endpointID) +{ + for (const endpointInfo of status?.details?.inputs) + if (endpointInfo.endpointID == endpointID) + return createLabelledControl (patchConnection, endpointInfo); + + return undefined; +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-patch-connection.js b/assets/example_patches/Replicant/cmaj_api/cmaj-patch-connection.js new file mode 100644 index 00000000..2fff73c5 --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-patch-connection.js @@ -0,0 +1,215 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { EventListenerList } from "./cmaj-event-listener-list.js" + +//============================================================================== +/** This class implements the API and much of the logic for communicating with + * an instance of a patch that is running. + */ +export class PatchConnection extends EventListenerList +{ + constructor() + { + super(); + } + + //============================================================================== + // Status-handling methods: + + /** Calling this will trigger an asynchronous callback to any status listeners with the + * patch's current state. Use addStatusListener() to attach a listener to receive it. + */ + requestStatusUpdate() { this.sendMessageToServer ({ type: "req_status" }); } + + /** Attaches a listener function that will be called whenever the patch's status changes. + * The function will be called with a parameter object containing many properties describing the status, + * including whether the patch is loaded, any errors, endpoint descriptions, its manifest, etc. + */ + addStatusListener (listener) { this.addEventListener ("status", listener); } + + /** Removes a listener that was previously added with addStatusListener() + */ + removeStatusListener (listener) { this.removeEventListener ("status", listener); } + + /** Causes the patch to be reset to its "just loaded" state. */ + resetToInitialState() { this.sendMessageToServer ({ type: "req_reset" }); } + + //============================================================================== + // Methods for sending data to input endpoints: + + /** Sends a value to one of the patch's input endpoints. + * + * This can be used to send a value to either an 'event' or 'value' type input endpoint. + * If the endpoint is a 'value' type, then the rampFrames parameter can optionally be used to specify + * the number of frames over which the current value should ramp to the new target one. + * The value parameter will be coerced to the type that is expected by the endpoint. So for + * examples, numbers will be converted to float or integer types, javascript objects and arrays + * will be converted into more complex types in as good a fashion is possible. + */ + sendEventOrValue (endpointID, value, rampFrames, timeoutMillisecs) { this.sendMessageToServer ({ type: "send_value", id: endpointID, value, rampFrames, timeout: timeoutMillisecs }); } + + /** Sends a short MIDI message value to a MIDI endpoint. + * The value must be a number encoded with `(byte0 << 16) | (byte1 << 8) | byte2`. + */ + sendMIDIInputEvent (endpointID, shortMIDICode) { this.sendEventOrValue (endpointID, { message: shortMIDICode }); } + + /** Tells the patch that a series of changes that constitute a gesture is about to take place + * for the given endpoint. Remember to call sendParameterGestureEnd() after they're done! + */ + sendParameterGestureStart (endpointID) { this.sendMessageToServer ({ type: "send_gesture_start", id: endpointID }); } + + /** Tells the patch that a gesture started by sendParameterGestureStart() has finished. + */ + sendParameterGestureEnd (endpointID) { this.sendMessageToServer ({ type: "send_gesture_end", id: endpointID }); } + + //============================================================================== + // Stored state control methods: + + /** Requests a callback to any stored-state value listeners with the current value of a given key-value pair. + * To attach a listener to receive these events, use addStoredStateValueListener(). + * @param {string} key + */ + requestStoredStateValue (key) { this.sendMessageToServer ({ type: "req_state_value", key: key }); } + + /** Modifies a key-value pair in the patch's stored state. + * @param {string} key + * @param {Object} newValue + */ + sendStoredStateValue (key, newValue) { this.sendMessageToServer ({ type: "send_state_value", key: key, value: newValue }); } + + /** Attaches a listener function that will be called when any key-value pair in the stored state is changed. + * The listener function will receive a message parameter with properties 'key' and 'value'. + */ + addStoredStateValueListener (listener) { this.addEventListener ("state_key_value", listener); } + + /** Removes a listener that was previously added with addStoredStateValueListener(). + */ + removeStoredStateValueListener (listener) { this.removeEventListener ("state_key_value", listener); } + + /** Applies a complete stored state to the patch. + * To get the current complete state, use requestFullStoredState(). + */ + sendFullStoredState (fullState) { this.sendMessageToServer ({ type: "send_full_state", value: fullState }); } + + /** Asynchronously requests the full stored state of the patch. + * The listener function that is supplied will be called asynchronously with the state as its argument. + */ + requestFullStoredState (callback) + { + const replyType = "fullstate_response_" + (Math.floor (Math.random() * 100000000)).toString(); + this.addSingleUseListener (replyType, callback); + this.sendMessageToServer ({ type: "req_full_state", replyType: replyType }); + } + + //============================================================================== + // Listener methods: + + /** Attaches a listener function that will receive updates with the events or audio data + * that is being sent or received by an endpoint. + * + * If the endpoint is an event or value, the callback will be given an argument which is + * the new value. + * + * If the endpoint has the right shape to be treated as "audio" then the callback will receive + * a stream of updates of the min/max range of chunks of data that is flowing through it. + * There will be one callback per chunk of data, and the size of chunks is specified by + * the optional granularity parameter. + * + * @param {string} endpointID + * @param {number} granularity - if defined, this specifies the number of frames per callback + * @param {boolean} sendFullAudioData - if false, the listener will receive an argument object containing + * two properties 'min' and 'max', which are each an array of values, one element per audio + * channel. This allows you to find the highest and lowest samples in that chunk for each channel. + * If sendFullAudioData is true, the listener's argument will have a property 'data' which is an + * array containing one array per channel of raw audio samples data. + */ + addEndpointListener (endpointID, listener, granularity, sendFullAudioData) + { + listener.eventID = "event_" + endpointID + "_" + (Math.floor (Math.random() * 100000000)).toString(); + this.addEventListener (listener.eventID, listener); + this.sendMessageToServer ({ type: "add_endpoint_listener", endpoint: endpointID, replyType: + listener.eventID, granularity: granularity, fullAudioData: sendFullAudioData }); + } + + /** Removes a listener that was previously added with addEndpointListener() + * @param {string} endpointID + */ + removeEndpointListener (endpointID, listener) + { + this.removeEventListener (listener.eventID, listener); + this.sendMessageToServer ({ type: "remove_endpoint_listener", endpoint: endpointID, replyType: listener.eventID }); + } + + /** This will trigger an asynchronous callback to any parameter listeners that are + * attached, providing them with its up-to-date current value for the given endpoint. + * Use addAllParameterListener() to attach a listener to receive the result. + * @param {string} endpointID + */ + requestParameterValue (endpointID) { this.sendMessageToServer ({ type: "req_param_value", id: endpointID }); } + + /** Attaches a listener function which will be called whenever the value of a specific parameter changes. + * The listener function will be called with an argument which is the new value. + * @param {string} endpointID + */ + addParameterListener (endpointID, listener) { this.addEventListener ("param_value_" + endpointID.toString(), listener); } + + /** Removes a listener that was previously added with addParameterListener() + * @param {string} endpointID + */ + removeParameterListener (endpointID, listener) { this.removeEventListener ("param_value_" + endpointID.toString(), listener); } + + /** Attaches a listener function which will be called whenever the value of any parameter changes in the patch. + * The listener function will be called with an argument object with the fields 'endpointID' and 'value'. + */ + addAllParameterListener (listener) { this.addEventListener ("param_value", listener); } + + /** Removes a listener that was previously added with addAllParameterListener() + */ + removeAllParameterListener (listener) { this.removeEventListener ("param_value", listener); } + + /** This takes a relative path to an asset within the patch bundle, and converts it to a + * path relative to the root of the browser that is showing the view. + * + * You need you use this in your view code to translate your asset URLs to a form that + * can be safely used in your view's HTML DOM (e.g. in its CSS). This is needed because the + * host's HTTP server (which is delivering your view pages) may have a different '/' root + * than the root of your patch (e.g. if a single server is serving multiple patch GUIs). + * + * @param {string} path + */ + getResourceAddress (path) { return path; } + + //============================================================================== + // Private methods follow this point.. + + /** @private */ + deliverMessageFromServer (msg) + { + if (msg.type === "status") + this.manifest = msg.message?.manifest; + + if (msg.type == "param_value") + this.dispatchEvent ("param_value_" + msg.message.endpointID, msg.message.value); + + this.dispatchEvent (msg.type, msg.message); + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-patch-view.js b/assets/example_patches/Replicant/cmaj_api/cmaj-patch-view.js new file mode 100644 index 00000000..8052a30e --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-patch-view.js @@ -0,0 +1,125 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js" + +/** Returns a list of types of view that can be created for this patch. + */ +export function getAvailableViewTypes (patchConnection) +{ + if (! patchConnection) + return []; + + if (patchConnection.manifest?.view?.src) + return ["custom", "generic"]; + + return ["generic"]; +} + +/** Creates and returns a HTMLElement view which can be shown to control this patch. + * + * If no preferredType argument is supplied, this will return either a custom patch-specific + * view (if the manifest specifies one), or a generic view if not. The preferredType argument + * can be used to choose one of the types of view returned by getAvailableViewTypes(). + * + * @param {PatchConnection} patchConnection - the connection to use + * @param {string} preferredType - the name of the type of view to open, e.g. "generic" + * or the name of one of the views in the manifest + * @returns {HTMLElement} a HTMLElement that can be displayed as the patch GUI + */ +export async function createPatchView (patchConnection, preferredType) +{ + if (patchConnection?.manifest) + { + let view = patchConnection.manifest.view; + + if (view && preferredType === "generic") + if (view.src) + view = undefined; + + const viewModuleURL = view?.src ? patchConnection.getResourceAddress (view.src) : "./cmaj-generic-patch-view.js"; + const viewModule = await import (viewModuleURL); + const patchView = await viewModule?.default (patchConnection); + + if (patchView) + { + patchView.style.display = "block"; + + if (view?.width > 10) + patchView.style.width = view.width + "px"; + else + patchView.style.width = undefined; + + if (view?.height > 10) + patchView.style.height = view.height + "px"; + else + patchView.style.height = undefined; + + return patchView; + } + } + + return undefined; +} + +/** If a patch view declares itself to be scalable, this will attempt to scale it to fit + * into a given parent element. + * + * @param {HTMLElement} view - the patch view + * @param {HTMLElement} parentToScale - the patch view's direct parent element, to which + * the scale factor will be applied + * @param {HTMLElement} parentContainerToFitTo - an outer parent of the view, whose bounds + * the view will be made to fit + */ +export function scalePatchViewToFit (view, parentToScale, parentContainerToFitTo) +{ + function getClientSize (view) + { + const clientStyle = getComputedStyle (view); + + return { + width: view.clientHeight - parseFloat (clientStyle.paddingTop) - parseFloat (clientStyle.paddingBottom), + height: view.clientWidth - parseFloat (clientStyle.paddingLeft) - parseFloat (clientStyle.paddingRight) + }; + } + + const scaleLimits = view.getScaleFactorLimits?.(); + + if (scaleLimits && (scaleLimits.minScale || scaleLimits.maxScale)) + { + const minScale = scaleLimits.minScale || 0.25; + const maxScale = scaleLimits.maxScale || 5.0; + + const targetSize = getClientSize (parentContainerToFitTo); + const clientSize = getClientSize (view); + + const scaleW = targetSize.width / clientSize.width; + const scaleH = targetSize.height / clientSize.height; + + const scale = Math.min (maxScale, Math.max (minScale, Math.min (scaleW, scaleH))); + + parentToScale.style.transform = `scale(${scale})`; + } + else + { + parentToScale.style.transform = "none"; + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-piano-keyboard.js b/assets/example_patches/Replicant/cmaj_api/cmaj-piano-keyboard.js new file mode 100644 index 00000000..23ef7a1b --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-piano-keyboard.js @@ -0,0 +1,460 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 The Cmajor Toolkit +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// The Cmajor project is subject to commercial or open-source licensing. +// You may use it under the terms of the GPLv3 (see www.gnu.org/licenses), or +// visit https://cmajor.dev to learn about our commercial licence options. +// +// CMAJOR IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER +// EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE +// DISCLAIMED. + +import * as midi from "./cmaj-midi-helpers.js" + +/** + * An general-purpose on-screen piano keyboard component that allows clicks or + * key-presses to be used to play things. + * + * To receive events, you can attach "note-down" and "note-up" event listeners via + * the standard HTMLElement/EventTarget event system, e.g. + * + * myKeyboardElement.addEventListener("note-down", (note) => { ...handle note on... }); + * myKeyboardElement.addEventListener("note-up", (note) => { ...handle note off... }); + * + * The `note` object will contain a `note` property with the MIDI note number. + * (And obviously you can remove them with removeEventListener) + * + * Or, if you're connecting the keyboard to a PatchConnection, you can use the helper + * method attachToPatchConnection() to create and attach some suitable listeners. + * + */ +export default class PianoKeyboard extends HTMLElement +{ + constructor ({ naturalNoteWidth, + accidentalWidth, + accidentalPercentageHeight, + naturalNoteBorder, + accidentalNoteBorder, + pressedNoteColour } = {}) + { + super(); + + this.naturalWidth = naturalNoteWidth || 20; + this.accidentalWidth = accidentalWidth || 12; + this.accidentalPercentageHeight = accidentalPercentageHeight || 66; + this.naturalBorder = naturalNoteBorder || "2px solid #333"; + this.accidentalBorder = accidentalNoteBorder || "2px solid #333"; + this.pressedColour = pressedNoteColour || "#8ad"; + + this.root = this.attachShadow({ mode: "open" }); + + this.root.addEventListener ("mousedown", (event) => this.handleMouse (event, true, false) ); + this.root.addEventListener ("mouseup", (event) => this.handleMouse (event, false, true) ); + this.root.addEventListener ("mousemove", (event) => this.handleMouse (event, false, false) ); + this.root.addEventListener ("mouseenter", (event) => this.handleMouse (event, false, false) ); + this.root.addEventListener ("mouseout", (event) => this.handleMouse (event, false, false) ); + + this.addEventListener ("keydown", (event) => this.handleKey (event, true)); + this.addEventListener ("keyup", (event) => this.handleKey (event, false)); + this.addEventListener ("focusout", (event) => this.allNotesOff()); + + this.currentDraggedNote = -1; + this.currentExternalNotesOn = new Set(); + this.currentKeyboardNotes = new Set(); + this.currentPlayedNotes = new Set(); + this.currentDisplayedNotes = new Set(); + this.notes = []; + this.currentTouches = new Map(); + + this.refreshHTML(); + + for (let child of this.root.children) + { + child.addEventListener ("touchstart", (event) => this.touchStart (event) ); + child.addEventListener ("touchend", (event) => this.touchEnd (event) ); + } + } + + static get observedAttributes() + { + return ["root-note", "note-count", "key-map"]; + } + + get config() + { + return { + rootNote: parseInt(this.getAttribute("root-note") || "36"), + numNotes: parseInt(this.getAttribute("note-count") || "61"), + keymap: this.getAttribute("key-map") || "KeyA KeyW KeyS KeyE KeyD KeyF KeyT KeyG KeyY KeyH KeyU KeyJ KeyK KeyO KeyL KeyP Semicolon", + }; + } + + /** This attaches suitable listeners to make this keyboard control the given MIDI + * endpoint of a PatchConnection object. Use detachPatchConnection() to remove + * a connection later on. + * + * @param {PatchConnection} patchConnection + * @param {string} midiInputEndpointID + */ + attachToPatchConnection (patchConnection, midiInputEndpointID) + { + const velocity = 100; + + const callbacks = { + noteDown: e => patchConnection.sendMIDIInputEvent (midiInputEndpointID, 0x900000 | (e.detail.note << 8) | velocity), + noteUp: e => patchConnection.sendMIDIInputEvent (midiInputEndpointID, 0x800000 | (e.detail.note << 8) | velocity), + midiIn: e => this.handleExternalMIDI (e.message), + midiInputEndpointID + }; + + if (! this.callbacks) + this.callbacks = new Map(); + + this.callbacks.set (patchConnection, callbacks); + + this.addEventListener ("note-down", callbacks.noteDown); + this.addEventListener ("note-up", callbacks.noteUp); + patchConnection.addEndpointListener (midiInputEndpointID, callbacks.midiIn); + } + + /** This removes the connection to a PatchConnection object that was previously attached + * with attachToPatchConnection(). + * + * @param {PatchConnection} patchConnection + */ + detachPatchConnection (patchConnection) + { + const callbacks = this.callbacks.get (patchConnection); + + if (callbacks) + { + this.removeEventListener ("note-down", callbacks.noteDown); + this.removeEventListener ("note-up", callbacks.noteUp); + patchConnection.removeEndpointListener (callbacks.midiInputEndpointID, callbacks.midiIn); + } + + this.callbacks[patchConnection] = undefined; + } + + //============================================================================== + /** Can be overridden to return the color to use for a note index */ + getNoteColour (note) { return undefined; } + + /** Can be overridden to return the text label to draw on a note index */ + getNoteLabel (note) { return midi.getChromaticScaleIndex (note) == 0 ? midi.getNoteNameWithOctaveNumber (note) : ""; } + + /** Clients should call this to deliver a MIDI message, which the keyboard will use to + * highlight the notes that are currently playing. + */ + handleExternalMIDI (message) + { + if (midi.isNoteOn (message)) + { + const note = midi.getNoteNumber (message); + this.currentExternalNotesOn.add (note); + this.refreshActiveNoteElements(); + } + else if (midi.isNoteOff (message)) + { + const note = midi.getNoteNumber (message); + this.currentExternalNotesOn.delete (note); + this.refreshActiveNoteElements(); + } + } + + /** This method will be called when the user plays a note. The default behaviour is + * to dispath an event, but you could override this if you needed to. + */ + sendNoteOn (note) { this.dispatchEvent (new CustomEvent('note-down', { detail: { note: note }})); } + + /** This method will be called when the user releases a note. The default behaviour is + * to dispath an event, but you could override this if you needed to. + */ + sendNoteOff (note) { this.dispatchEvent (new CustomEvent('note-up', { detail: { note: note } })); } + + /** Clients can call this to force all the notes to turn off, e.g. in a "panic". */ + allNotesOff() + { + this.setDraggedNote (-1); + + for (let note of this.currentKeyboardNotes.values()) + this.removeKeyboardNote (note); + + this.currentExternalNotesOn.clear(); + this.refreshActiveNoteElements(); + } + + setDraggedNote (newNote) + { + if (newNote != this.currentDraggedNote) + { + if (this.currentDraggedNote >= 0) + this.sendNoteOff (this.currentDraggedNote); + + this.currentDraggedNote = newNote; + + if (this.currentDraggedNote >= 0) + this.sendNoteOn (this.currentDraggedNote); + + this.refreshActiveNoteElements(); + } + } + + addKeyboardNote (note) + { + if (! this.currentKeyboardNotes.has (note)) + { + this.sendNoteOn (note); + this.currentKeyboardNotes.add (note); + this.refreshActiveNoteElements(); + } + } + + removeKeyboardNote (note) + { + if (this.currentKeyboardNotes.has (note)) + { + this.sendNoteOff (note); + this.currentKeyboardNotes.delete (note); + this.refreshActiveNoteElements(); + } + } + + isNoteActive (note) + { + return note == this.currentDraggedNote + || this.currentExternalNotesOn.has (note) + || this.currentKeyboardNotes.has (note); + } + + //============================================================================== + /** @private */ + touchEnd (event) + { + for (const touch of event.changedTouches) + { + const note = this.currentTouches.get (touch.identifier); + this.currentTouches.delete (touch.identifier); + this.removeKeyboardNote (note); + } + + event.preventDefault(); + } + + /** @private */ + touchStart (event) + { + for (const touch of event.changedTouches) + { + const note = touch.target.id.substring (4); + this.currentTouches.set (touch.identifier, note); + this.addKeyboardNote (note); + } + + event.preventDefault(); + } + + /** @private */ + handleMouse (event, isDown, isUp) + { + if (isDown) + this.isDragging = true; + + if (this.isDragging) + { + let newActiveNote = -1; + + if (event.buttons != 0 && event.type != "mouseout") + { + const note = event.target.id.substring (4); + + if (note !== undefined) + newActiveNote = parseInt (note); + } + + this.setDraggedNote (newActiveNote); + + if (! isDown) + event.preventDefault(); + } + + if (isUp) + this.isDragging = false; + } + + /** @private */ + handleKey (event, isDown) + { + const config = this.config; + const index = config.keymap.split (" ").indexOf (event.code); + + if (index >= 0) + { + const note = Math.floor ((config.rootNote + (config.numNotes / 4) + 11) / 12) * 12 + index; + + if (isDown) + this.addKeyboardNote (note); + else + this.removeKeyboardNote (note); + + event.preventDefault(); + } + } + + /** @private */ + refreshHTML() + { + this.root.innerHTML = `${this.getNoteElements()}`; + + for (let i = 0; i < 128; ++i) + { + const elem = this.shadowRoot.getElementById (`note${i.toString()}`); + this.notes.push ({ note: i, element: elem }); + } + + this.style.maxWidth = window.getComputedStyle (this).scrollWidth; + } + + /** @private */ + refreshActiveNoteElements() + { + for (let note of this.notes) + { + if (note.element) + { + if (this.isNoteActive (note.note)) + note.element.classList.add ("active"); + else + note.element.classList.remove ("active"); + } + } + } + + /** @private */ + getAccidentalOffset (note) + { + let index = midi.getChromaticScaleIndex (note); + + let negativeOffset = -this.accidentalWidth / 16; + let positiveOffset = 3 * this.accidentalWidth / 16; + + const accOffset = this.naturalWidth - (this.accidentalWidth / 2); + const offsets = [ 0, negativeOffset, 0, positiveOffset, 0, 0, negativeOffset, 0, 0, 0, positiveOffset, 0 ]; + + return accOffset + offsets[index]; + } + + /** @private */ + getNoteElements() + { + const config = this.config; + let naturals = "", accidentals = ""; + let x = 0; + + for (let i = 0; i < config.numNotes; ++i) + { + const note = config.rootNote + i; + const name = this.getNoteLabel (note); + + if (midi.isNatural (note)) + { + naturals += `

${name}

`; + } + else + { + let accidentalOffset = this.getAccidentalOffset (note); + accidentals += `
`; + } + + if (midi.isNatural (note + 1) || i == config.numNotes - 1) + x += this.naturalWidth; + } + + this.style.maxWidth = (x + 1) + "px"; + + return `
+ ${naturals} + ${accidentals} +
`; + } + + /** @private */ + getCSS() + { + let extraColours = ""; + const config = this.config; + + for (let i = 0; i < config.numNotes; ++i) + { + const note = config.rootNote + i; + const colourOverride = this.getNoteColour (note); + + if (colourOverride) + extraColours += `#note${note}:not(.active) { background: ${colourOverride}; }`; + } + + return ` + * { + box-sizing: border-box; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + margin: 0; + padding: 0; + } + + :host { + display: block; + overflow: auto; + position: relative; + } + + .natural-note { + position: absolute; + border: ${this.naturalBorder}; + background: #fff; + width: ${this.naturalWidth}px; + height: 100%; + + display: flex; + align-items: end; + justify-content: center; + } + + p { + pointer-events: none; + text-align: center; + font-size: 0.7rem; + color: grey; + } + + .accidental-note { + position: absolute; + top: 0; + border: ${this.accidentalBorder}; + background: #333; + width: ${this.accidentalWidth}px; + height: ${this.accidentalPercentageHeight}%; + } + + .note-holder { + position: relative; + height: 100%; + } + + .active { + background: ${this.pressedColour}; + } + + ${extraColours} + ` + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj-server-session.js b/assets/example_patches/Replicant/cmaj_api/cmaj-server-session.js new file mode 100644 index 00000000..813f5fcd --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj-server-session.js @@ -0,0 +1,452 @@ +// +// ,ad888ba, 88 +// d8"' "8b +// d8 88,dba,,adba, ,aPP8A.A8 88 +// Y8, 88 88 88 88 88 88 +// Y8a. .a8P 88 88 88 88, ,88 88 (C)2024 Cmajor Software Ltd +// '"Y888Y"' 88 88 88 '"8bbP"Y8 88 https://cmajor.dev +// ,88 +// 888P" +// +// This file may be used under the terms of the ISC license: +// +// Permission to use, copy, modify, and/or distribute this software for any purpose with or +// without fee is hereby granted, provided that the above copyright notice and this permission +// notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +// CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +// WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import { PatchConnection } from "./cmaj-patch-connection.js" +import { EventListenerList } from "./cmaj-event-listener-list.js" + + +//============================================================================== +/* + * This class provides the API and manages the communication protocol between + * a javascript application and a Cmajor session running on some kind of server + * (which may be local or remote). + * + * This is an abstract base class: some kind of transport layer will create a + * subclass of ServerSession which a client application can then use to control + * and interact with the server. + */ +export class ServerSession extends EventListenerList +{ + /** A server session must be given a unique sessionID. + * @param {string} sessionID - this must be a unique string which is safe for + * use as an identifier or filename + */ + constructor (sessionID) + { + super(); + + this.sessionID = sessionID; + this.activePatchConnections = new Set(); + this.status = { connected: false, loaded: false }; + this.lastServerMessageTime = Date.now(); + this.checkForServerTimer = setInterval (() => this.checkServerStillExists(), 2000); + } + + /** Call `dispose()` when this session is no longer needed and should be released. */ + dispose() + { + if (this.checkForServerTimer) + { + clearInterval (this.checkForServerTimer); + this.checkForServerTimer = undefined; + } + + this.status = { connected: false, loaded: false }; + } + + //============================================================================== + // Session status methods: + + /** Attaches a listener function which will be called when the session status changes. + * The listener will be called with an argument object containing lots of properties + * describing the state, including any errors, loaded patch manifest, etc. + */ + addStatusListener (listener) { this.addEventListener ("session_status", listener); } + + /** Removes a listener that was previously added by `addStatusListener()` + */ + removeStatusListener (listener) { this.removeEventListener ("session_status", listener); } + + /** Asks the server to asynchronously send a status update message with the latest status. + */ + requestSessionStatus() { this.sendMessageToServer ({ type: "req_session_status" }); } + + /** Returns the session's last known status object. */ + getCurrentStatus() { return this.status; } + + //============================================================================== + // Patch loading: + + /** Asks the server to load the specified patch into our session. + */ + loadPatch (patchFileToLoad) + { + this.currentPatchLocation = patchFileToLoad; + this.sendMessageToServer ({ type: "load_patch", file: patchFileToLoad }); + } + + /** Tells the server to asynchronously generate a list of patches that it has access to. + * The function provided will be called back with an array of manifest objects describing + * each of the patches. + */ + requestAvailablePatchList (callbackFunction) + { + const replyType = this.createReplyID ("patchlist_"); + this.addSingleUseListener (replyType, callbackFunction); + this.sendMessageToServer ({ type: "req_patchlist", + replyType: replyType }); + } + + /** Creates and returns a new PatchConnection object which can be used to control the + * patch that this session has loaded. + */ + createPatchConnection() + { + class ServerPatchConnection extends PatchConnection + { + constructor (session) + { + super(); + this.session = session; + this.manifest = session.status?.manifest; + this.session.activePatchConnections.add (this); + } + + dispose() + { + this.session.activePatchConnections.delete (this); + this.session = undefined; + } + + sendMessageToServer (message) + { + this.session?.sendMessageToServer (message); + } + + getResourceAddress (path) + { + if (! this.session?.status?.httpRootURL) + return undefined; + + return this.session.status.httpRootURL + + (path.startsWith ("/") ? path.substr (1) : path); + } + } + + return new ServerPatchConnection (this); + } + + //============================================================================== + // Audio input source handling: + + /** + * Sets a custom audio input source for a particular endpoint. + * + * When a source is changed, a callback is sent to any audio input mode listeners (see + * `addAudioInputModeListener()`) + * + * @param {Object} endpointID + * @param {boolean} shouldMute - if true, the endpoint will be muted + * @param {Uint8Array | Array} fileDataToPlay - if this is some kind of array containing + * binary data that can be parsed as an audio file, then it will be sent across for the + * server to play as a looped input sample. + */ + setAudioInputSource (endpointID, shouldMute, fileDataToPlay) + { + const loopFile = "_audio_source_" + endpointID; + + if (fileDataToPlay) + { + this.registerFile (loopFile, + { + size: fileDataToPlay.byteLength, + read: (start, length) => { return new Blob ([fileDataToPlay.slice (start, start + length)]); } + }); + + this.sendMessageToServer ({ type: "set_custom_audio_input", + endpoint: endpointID, + file: loopFile }); + } + else + { + this.removeFile (loopFile); + + this.sendMessageToServer ({ type: "set_custom_audio_input", + endpoint: endpointID, + mute: !! shouldMute }); + } + } + + /** Attaches a listener function to be told when the input source for a particular + * endpoint is changed by a call to `setAudioInputSource()`. + */ + addAudioInputModeListener (endpointID, listener) { this.addEventListener ("audio_input_mode_" + endpointID, listener); } + + /** Removes a listener previously added with `addAudioInputModeListener()` */ + removeAudioInputModeListener (endpointID, listener) { this.removeEventListener ("audio_input_mode_" + endpointID, listener); } + + /** Asks the server to send an update with the latest status to any audio mode listeners that + * are attached to the given endpoint. + * @param {string} endpointID + */ + requestAudioInputMode (endpointID) { this.sendMessageToServer ({ type: "req_audio_input_mode", endpoint: endpointID }); } + + //============================================================================== + // Audio device methods: + + /** Enables or disables audio playback. + * When playback state changes, a status update is sent to any status listeners. + * @param {boolean} shouldBeActive + */ + setAudioPlaybackActive (shouldBeActive) { this.sendMessageToServer ({ type: "set_audio_playback_active", active: shouldBeActive }); } + + /** Asks the server to apply a new set of audio device properties. + * The properties object uses the same format as the object that is passed to the listeners + * (see `addAudioDevicePropertiesListener()`). + */ + setAudioDeviceProperties (newProperties) { this.sendMessageToServer ({ type: "set_audio_device_props", properties: newProperties }); } + + /** Attaches a listener function which will be called when the audio device properties are + * changed. + * + * You can remove the listener when it's no longer needed with `removeAudioDevicePropertiesListener()`. + * + * @param listener - this callback will receive an argument object containing all the + * details about the device. + */ + addAudioDevicePropertiesListener (listener) { this.addEventListener ("audio_device_properties", listener); } + + /** Removes a listener that was added with `addAudioDevicePropertiesListener()` */ + removeAudioDevicePropertiesListener (listener) { this.removeEventListener ("audio_device_properties", listener); } + + /** Causes an asynchronous callback to any audio device listeners that are registered. */ + requestAudioDeviceProperties() { this.sendMessageToServer ({ type: "req_audio_device_props" }); } + + //============================================================================== + /** Asks the server to asynchronously generate some code from the currently loaded patch. + * + * @param {string} codeType - this must be one of the strings that are listed in the + * status's `codeGenTargets` property. For example, "cpp" + * would request a C++ version of the patch. + * @param {Object} [extraOptions] - this optionally provides target-specific properties. + * @param callbackFunction - this function will be called with the result when it has + * been generated. Its argument will be an object containing the + * code, errors and other metadata about the patch. + */ + requestGeneratedCode (codeType, extraOptions, callbackFunction) + { + const replyType = this.createReplyID ("codegen_"); + this.addSingleUseListener (replyType, callbackFunction); + this.sendMessageToServer ({ type: "req_codegen", + codeType: codeType, + options: extraOptions, + replyType: replyType }); + } + + //============================================================================== + // File change monitoring: + + /** Attaches a listener to be told when a file change is detected in the currently-loaded + * patch. The function will be called with an object that gives rough details about the + * type of change, i.e. whether it's a manifest or asset file, or a cmajor file, but it + * won't provide any information about exactly which files are involved. + */ + addFileChangeListener (listener) { this.addEventListener ("patch_source_changed", listener); } + + /** Removes a listener that was previously added with `addFileChangeListener()`. + */ + removeFileChangeListener (listener) { this.removeEventListener ("patch_source_changed", listener); } + + //============================================================================== + // CPU level monitoring methods: + + /** Attaches a listener function which will be sent messages containing CPU info. + * To remove the listener, call `removeCPUListener()`. To change the rate of these + * messages, use `setCPULevelUpdateRate()`. + */ + addCPUListener (listener) { this.addEventListener ("cpu_info", listener); this.updateCPULevelUpdateRate(); } + + /** Removes a listener that was previously attached with `addCPUListener()`. */ + removeCPUListener (listener) { this.removeEventListener ("cpu_info", listener); this.updateCPULevelUpdateRate(); } + + /** Changes the frequency at which CPU level update messages are sent to listeners. */ + setCPULevelUpdateRate (framesPerUpdate) { this.cpuFramesPerUpdate = framesPerUpdate; this.updateCPULevelUpdateRate(); } + + /** Attaches a listener to be told when a file change is detected in the currently-loaded + * patch. The function will be called with an object that gives rough details about the + * type of change, i.e. whether it's a manifest or asset file, or a cmajor file, but it + * won't provide any information about exactly which files are involved. + */ + addInfiniteLoopListener (listener) { this.addEventListener ("infinite_loop_detected", listener); } + + /** Removes a listener that was previously added with `addFileChangeListener()`. */ + removeInfiniteLoopListener (listener) { this.removeEventListener ("infinite_loop_detected", listener); } + + //============================================================================== + /** Registers a virtual file with the server, under the given name. + * + * @param {string} filename - the full path name of the file + * @param {Object} contentProvider - this object must have a property called `size` which is a + * constant size in bytes for the file, and a method `read (offset, size)` which + * returns an array (or UInt8Array) of bytes for the data in a given chunk of the file. + * The server may repeatedly call this method at any time until `removeFile()` is + * called to deregister the file. + */ + registerFile (filename, contentProvider) + { + if (! this.files) + this.files = new Map(); + + this.files.set (filename, contentProvider); + + this.sendMessageToServer ({ type: "register_file", + filename: filename, + size: contentProvider.size }); + } + + /** Removes a file that was previously registered with `registerFile()`. */ + removeFile (filename) + { + this.sendMessageToServer ({ type: "remove_file", + filename: filename }); + this.files?.delete (filename); + } + + //============================================================================== + // Private methods from this point... + + /** An implementation subclass must call this when the session first connects + * @private + */ + handleSessionConnection() + { + if (! this.status.connected) + { + this.requestSessionStatus(); + this.requestAudioDeviceProperties(); + + if (this.currentPatchLocation) + { + this.loadPatch (this.currentPatchLocation); + this.currentPatchLocation = undefined; + } + } + } + + /** An implementation subclass must call this when a message arrives + * @private + */ + handleMessageFromServer (msg) + { + this.lastServerMessageTime = Date.now(); + const type = msg.type; + const message = msg.message; + + switch (type) + { + case "cpu_info": + case "audio_device_properties": + case "patch_source_changed": + case "infinite_loop_detected": + this.dispatchEvent (type, message); + break; + + case "session_status": + message.connected = true; + this.setNewStatus (message); + break; + + case "req_file_read": + this.handleFileReadRequest (message); + break; + + case "ping": + this.sendMessageToServer ({ type: "ping" }); + break; + + default: + if (type.startsWith ("audio_input_mode_") || type.startsWith ("reply_")) + { + this.dispatchEvent (type, message); + break; + } + + for (const c of this.activePatchConnections) + c.deliverMessageFromServer (msg); + + break; + } + } + + /** @private */ + checkServerStillExists() + { + if (Date.now() > this.lastServerMessageTime + 10000) + this.setNewStatus ({ + connected: false, + loaded: false, + status: "Cannot connect to the Cmajor server" + }); + } + + /** @private */ + setNewStatus (newStatus) + { + this.status = newStatus; + this.dispatchEvent ("session_status", this.status); + this.updateCPULevelUpdateRate(); + } + + /** @private */ + updateCPULevelUpdateRate() + { + const rate = this.getNumListenersForType ("cpu_info") > 0 ? (this.cpuFramesPerUpdate || 15000) : 0; + this.sendMessageToServer ({ type: "set_cpu_info_rate", + framesPerCallback: rate }); + } + + /** @private */ + handleFileReadRequest (request) + { + const contentProvider = this.files?.get (request?.file); + + if (contentProvider && request.offset !== null && request.size != 0) + { + const data = contentProvider.read (request.offset, request.size); + const reader = new FileReader(); + + reader.onloadend = (e) => + { + const base64 = e.target?.result?.split?.(",", 2)[1]; + + if (base64) + this.sendMessageToServer ({ type: "file_content", + file: request.file, + data: base64, + start: request.offset }); + }; + + reader.readAsDataURL (data); + } + } + + /** @private */ + createReplyID (stem) + { + return "reply_" + stem + this.createRandomID(); + } + + /** @private */ + createRandomID() + { + return (Math.floor (Math.random() * 100000000)).toString(); + } +} diff --git a/assets/example_patches/Replicant/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/Replicant/cmaj_api/cmaj_audio_worklet_helper.js new file mode 100644 index 00000000..39f68f9b --- /dev/null +++ b/assets/example_patches/Replicant/cmaj_api/cmaj_audio_worklet_helper.js @@ -0,0 +1,707 @@ + +import { PatchConnection } from "./cmaj-patch-connection.js" + +//============================================================================== +// N.B. code will be serialised to a string, so all `registerWorkletProcessor`s +// dependencies must be self contained and not capture things in the outer scope +async function serialiseWorkletProcessorFactoryToDataURI (WrapperClass, workletName) +{ + const serialisedInvocation = `(${registerWorkletProcessor.toString()}) ("${workletName}", ${WrapperClass.toString()});` + + let reader = new FileReader(); + reader.readAsDataURL (new Blob ([serialisedInvocation], { type: "text/javascript" })); + + return await new Promise (res => { reader.onloadend = () => res (reader.result); }); +} + +function registerWorkletProcessor (workletName, WrapperClass) +{ + function makeConsumeOutputEvents ({ wrapper, eventOutputs, dispatchOutputEvent }) + { + const outputEventHandlers = eventOutputs.map (({ endpointID }) => + { + const readCount = wrapper[`getOutputEventCount_${endpointID}`]?.bind (wrapper); + const reset = wrapper[`resetOutputEventCount_${endpointID}`]?.bind (wrapper); + const readEventAtIndex = wrapper[`getOutputEvent_${endpointID}`]?.bind (wrapper); + + return () => + { + const count = readCount(); + for (let i = 0; i < count; ++i) + dispatchOutputEvent (endpointID, readEventAtIndex (i)); + + reset(); + }; + }); + + return () => outputEventHandlers.forEach ((consume) => consume() ); + } + + function setInitialParameterValues (parametersMap) + { + for (const { initialise } of Object.values (parametersMap)) + initialise(); + } + + function makeEndpointMap (wrapper, endpoints, initialValueOverrides) + { + const toKey = ({ endpointType, endpointID }) => + { + switch (endpointType) + { + case "event": return `sendInputEvent_${endpointID}`; + case "value": return `setInputValue_${endpointID}`; + } + + throw "Unhandled endpoint type"; + }; + + const lookup = {}; + for (const { endpointID, endpointType, annotation, purpose } of endpoints) + { + const key = toKey ({ endpointType, endpointID }); + const wrapperUpdate = wrapper[key]?.bind (wrapper); + + const snapAndConstrainValue = (value) => + { + if (annotation.step != null) + value = Math.round (value / annotation.step) * annotation.step; + + if (annotation.min != null && annotation.max != null) + value = Math.min (Math.max (value, annotation.min), annotation.max); + + return value; + }; + + const update = (value, rampFrames) => + { + // N.B. value clamping and rampFrames from annotations not currently applied + const entry = lookup[endpointID]; + entry.cachedValue = value; + wrapperUpdate (value, rampFrames); + }; + + if (update) + { + const initialValue = initialValueOverrides[endpointID] ?? annotation?.init; + + lookup[endpointID] = { + snapAndConstrainValue, + update, + initialise: initialValue != null ? () => update (initialValue) : () => {}, + purpose, + cachedValue: undefined, + }; + } + } + + return lookup; + } + + function makeStreamEndpointHandler ({ wrapper, toEndpoints, wrapperMethodNamePrefix }) + { + const endpoints = toEndpoints (wrapper); + if (endpoints.length === 0) + return () => {}; + + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; + + return (channels, blockSize) => handleFrames (channels, blockSize); + } + + function makeInputStreamEndpointHandler (wrapper) + { + return makeStreamEndpointHandler ({ + wrapper, + toEndpoints: wrapper => wrapper.getInputEndpoints().filter (({ purpose }) => purpose === "audio in"), + wrapperMethodNamePrefix: "setInputStreamFrames", + }); + } + + function makeOutputStreamEndpointHandler (wrapper) + { + return makeStreamEndpointHandler ({ + wrapper, + toEndpoints: wrapper => wrapper.getOutputEndpoints().filter (({ purpose }) => purpose === "audio out"), + wrapperMethodNamePrefix: "getOutputFrames", + }); + } + + class WorkletProcessor extends AudioWorkletProcessor + { + static get parameterDescriptors() + { + return []; + } + + constructor ({ processorOptions, ...options }) + { + super (options); + + this.processImpl = undefined; + this.consumeOutputEvents = undefined; + + const { sessionID = Date.now() & 0x7fffffff, initialValueOverrides = {} } = processorOptions; + + const wrapper = new WrapperClass(); + + wrapper.initialise (sessionID, sampleRate) + .then (() => this.initialisePatch (wrapper, initialValueOverrides)) + .catch (error => { throw new Error (error)}); + } + + process (inputs, outputs) + { + const input = inputs[0]; + const output = outputs[0]; + + this.processImpl?.(input, output); + this.consumeOutputEvents?.(); + + return true; + } + + sendPatchMessage (payload) + { + this.port.postMessage ({ type: "patch", payload }); + } + + sendParameterValueChanged (endpointID, value) + { + this.sendPatchMessage ({ + type: "param_value", + message: { endpointID, value } + }); + } + + initialisePatch (wrapper, initialValueOverrides) + { + try + { + const inputParameters = wrapper.getInputEndpoints().filter (({ purpose }) => purpose === "parameter"); + const parametersMap = makeEndpointMap (wrapper, inputParameters, initialValueOverrides); + + setInitialParameterValues (parametersMap); + + const toParameterValuesWithKey = (endpointKey, parametersMap) => + { + const toValue = ([endpoint, { cachedValue }]) => ({ [endpointKey]: endpoint, value: cachedValue }); + return Object.entries (parametersMap).map (toValue); + }; + + const initialValues = toParameterValuesWithKey ("endpointID", parametersMap); + const initialState = wrapper.getState(); + + const resetState = () => + { + wrapper.restoreState (initialState); + + // N.B. update cache used for `req_param_value` messages (we don't currently read from the wasm heap) + setInitialParameterValues (parametersMap); + }; + + const isNonAudioOrParameterEndpoint = ({ purpose }) => ! ["audio in", "parameter"].includes (purpose); + const otherInputs = wrapper.getInputEndpoints().filter (isNonAudioOrParameterEndpoint); + const otherInputEndpointsMap = makeEndpointMap (wrapper, otherInputs, initialValueOverrides); + + const isEvent = ({ endpointType }) => endpointType === "event"; + const eventInputs = wrapper.getInputEndpoints().filter (isEvent); + const eventOutputs = wrapper.getOutputEndpoints().filter (isEvent); + + const makeEndpointListenerMap = (eventEndpoints) => + { + const listeners = {}; + + for (const { endpointID } of eventEndpoints) + listeners[endpointID] = []; + + return listeners; + }; + + const inputEventListeners = makeEndpointListenerMap (eventInputs); + const outputEventListeners = makeEndpointListenerMap (eventOutputs); + + this.consumeOutputEvents = makeConsumeOutputEvents ({ + eventOutputs, + wrapper, + dispatchOutputEvent: (endpointID, event) => + { + for (const { replyType } of outputEventListeners[endpointID] ?? []) + { + this.sendPatchMessage ({ + type: replyType, + message: event.event, // N.B. chucking away frame and typeIndex info for now + }); + } + }, + }); + + const blockSize = 128; + const prepareInputFrames = makeInputStreamEndpointHandler (wrapper); + const processOutputFrames = makeOutputStreamEndpointHandler (wrapper); + + this.processImpl = (input, output) => + { + prepareInputFrames (input, blockSize); + wrapper.advance (blockSize); + processOutputFrames (output, blockSize); + }; + + // N.B. the message port makes things straightforward, but it allocates (when sending + receiving). + // so, we aren't doing ourselves any favours. we probably ought to marshal raw bytes over to the gui in + // a pre-allocated lock-free message queue (using `SharedArrayBuffer` + `Atomic`s) and transform the raw + // messages there. + this.port.addEventListener ("message", e => + { + if (e.data.type !== "patch") + return; + + const msg = e.data.payload; + + switch (msg.type) + { + case "req_status": + { + this.sendPatchMessage ({ + type: "status", + message: { + details: { + inputs: wrapper.getInputEndpoints(), + outputs: wrapper.getOutputEndpoints(), + }, + sampleRate, + }, + }); + break; + } + + case "req_reset": + { + resetState(); + initialValues.forEach (v => this.sendParameterValueChanged (v.endpointID, v.value)); + break; + } + + case "req_param_value": + { + // N.B. keep a local cache here so that we can send the values back when requested. + // we could instead have accessors into the wasm heap. + const endpointID = msg.id; + const parameter = parametersMap[endpointID]; + if (! parameter) + return; + + const value = parameter.cachedValue; + this.sendParameterValueChanged (endpointID, value); + break; + } + + case "send_value": + { + const endpointID = msg.id; + const parameter = parametersMap[endpointID]; + + if (parameter) + { + const newValue = parameter.snapAndConstrainValue (msg.value); + parameter.update (newValue, msg.rampFrames); + + this.sendParameterValueChanged (endpointID, newValue); + return; + } + + const inputEndpoint = otherInputEndpointsMap[endpointID]; + + if (inputEndpoint) + { + inputEndpoint.update (msg.value); + + for (const { replyType } of inputEventListeners[endpointID] ?? []) + { + this.sendPatchMessage ({ + type: replyType, + message: inputEndpoint.cachedValue, + }); + } + } + break; + } + + case "send_gesture_start": break; + case "send_gesture_end": break; + + case "req_full_state": + this.sendPatchMessage ({ + type: msg?.replyType, + message: { + parameters: toParameterValuesWithKey ("name", parametersMap), + }, + }); + break; + + case "send_full_state": + { + const { parameters = [] } = e.data.payload?.value || []; + + for (const [endpointID, parameter] of Object.entries (parametersMap)) + { + const namedNextValue = parameters.find (({ name }) => name === endpointID); + + if (namedNextValue) + parameter.update (namedNextValue.value); + else + parameter.initialise(); + + this.sendParameterValueChanged (endpointID, parameter.cachedValue); + } + break; + } + + case "add_endpoint_listener": + { + const insertIfValidEndpoint = (lookup, msg) => + { + const endpointID = msg?.endpoint; + const listeners = lookup[endpointID] + + if (! listeners) + return false; + + return listeners.push ({ replyType: msg?.replyType }) > 0; + }; + + if (! insertIfValidEndpoint (inputEventListeners, msg)) + insertIfValidEndpoint (outputEventListeners, msg) + + break; + } + + case "remove_endpoint_listener": + { + const removeIfValidReplyType = (lookup, msg) => + { + const endpointID = msg?.endpoint; + const listeners = lookup[endpointID]; + + if (! listeners) + return false; + + const index = listeners.indexOf (msg?.replyType); + + if (index === -1) + return false; + + return listeners.splice (index, 1).length === 1; + }; + + if (! removeIfValidReplyType (inputEventListeners, msg)) + removeIfValidReplyType (outputEventListeners, msg) + + break; + } + + default: + break; + } + }); + + this.port.postMessage ({ type: "initialised" }); + this.port.start(); + } + catch (e) + { + this.port.postMessage (e.toString()); + } + } + } + + registerProcessor (workletName, WorkletProcessor); +} + +//============================================================================== +async function connectToAudioIn (audioContext, node) +{ + try + { + const input = await navigator.mediaDevices.getUserMedia ({ + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }}); + + if (! input) + throw new Error(); + + const source = audioContext.createMediaStreamSource (input); + + if (! source) + throw new Error(); + + source.connect (node); + } + catch (e) + { + console.warn (`Could not open audio input`); + } +} + +async function connectToMIDI (connection, midiEndpointID) +{ + try + { + if (! navigator.requestMIDIAccess) + throw new Error ("Web MIDI API not supported."); + + const midiAccess = await navigator.requestMIDIAccess ({ sysex: true, software: true }); + + for (const input of midiAccess.inputs.values()) + { + input.onmidimessage = ({ data }) => + connection.sendMIDIInputEvent (midiEndpointID, data[2] | (data[1] << 8) | (data[0] << 16)); + } + } + catch (e) + { + console.warn (`Could not open MIDI devices: ${e}`); + } +} + + +//============================================================================== +/** This class provides a PatchConnection that controls a Cmajor audio worklet + * node. + */ +export class AudioWorkletPatchConnection extends PatchConnection +{ + constructor (manifest) + { + super(); + + this.manifest = manifest; + this.cachedState = {}; + } + + //============================================================================== + /** Initialises this connection to load and control the given Cmajor class. + * + * @param {Object} WrapperClass - the generated Cmajor class + * @param {AudioContext} audioContext - a web audio AudioContext object + * @param {string} workletName - the name to give the new worklet that is created + * @param {number} sessionID - an integer to use for the session ID + * @param {Array} patchInputList - a list of the input endpoints that the patch provides + * @param {Object} initialValueOverrides - optional initial values for parameter endpoints + */ + async initialise (WrapperClass, + audioContext, + workletName, + sessionID, + initialValueOverrides) + { + this.audioContext = audioContext; + + const dataURI = await serialiseWorkletProcessorFactoryToDataURI (WrapperClass, workletName); + await audioContext.audioWorklet.addModule (dataURI); + + this.inputEndpoints = WrapperClass.prototype.getInputEndpoints(); + this.outputEndpoints = WrapperClass.prototype.getOutputEndpoints(); + + const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); + const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); + + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; + + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); + + const hasInput = inputChannelCount > 0; + const hasOutput = outputChannelCount > 0; + + const node = new AudioWorkletNode (audioContext, workletName, { + numberOfInputs: +hasInput, + numberOfOutputs: +hasOutput, + channelCountMode: "explicit", + channelCount: hasInput ? inputChannelCount : undefined, + outputChannelCount: hasOutput ? [outputChannelCount] : [], + + processorOptions: + { + sessionID, + initialValueOverrides + } + }); + + const waitUntilWorkletInitialised = async () => + { + return new Promise ((resolve) => + { + const filterForInitialised = (e) => + { + if (e.data.type === "initialised") + { + node.port.removeEventListener ("message", filterForInitialised); + resolve(); + } + }; + + node.port.addEventListener ("message", filterForInitialised); + }); + }; + + node.port.start(); + + await waitUntilWorkletInitialised(); + + this.audioNode = node; + + node.port.addEventListener ("message", e => + { + if (e.data.type === "patch") + { + const msg = e.data.payload; + + if (msg?.type === "status") + msg.message = { manifest: this.manifest, ...msg.message }; + + this.deliverMessageFromServer (msg) + } + }); + + this.startPatchWorker(); + } + + //============================================================================== + /** Attempts to connect this connection to the default audio and MIDI channels. + * This must only be called once initialise() has completed successfully. + * + * @param {AudioContext} audioContext - a web audio AudioContext object + */ + async connectDefaultAudioAndMIDI (audioContext) + { + if (! this.audioNode) + throw new Error ("AudioWorkletPatchConnection.initialise() must have been successfully completed before calling connectDefaultAudioAndMIDI()"); + + const getInputWithPurpose = (purpose) => + { + for (const i of this.inputEndpoints) + if (i.purpose === purpose) + return i.endpointID; + } + + const midiEndpointID = getInputWithPurpose ("midi in"); + + if (midiEndpointID) + connectToMIDI (this, midiEndpointID); + + if (getInputWithPurpose ("audio in")) + connectToAudioIn (audioContext, this.audioNode); + + this.audioNode.connect (audioContext.destination); + } + + //============================================================================== + sendMessageToServer (msg) + { + this.audioNode.port.postMessage ({ type: "patch", payload: msg }); + } + + requestStoredStateValue (key) + { + this.dispatchEvent ("state_key_value", { key, value: this.cachedState[key] }); + } + + sendStoredStateValue (key, newValue) + { + const changed = this.cachedState[key] != newValue; + + if (changed) + { + const shouldRemove = newValue == null; + if (shouldRemove) + { + delete this.cachedState[key]; + return; + } + + this.cachedState[key] = newValue; + // N.B. notifying the client only when updating matches behaviour of the patch player + this.dispatchEvent ("state_key_value", { key, value: newValue }); + } + } + + sendFullStoredState (fullState) + { + const currentStateCleared = (() => + { + const out = {}; + Object.keys (this.cachedState).forEach (k => out[k] = undefined); + return out; + })(); + + const incomingStateValues = fullState.values ?? {}; + const nextStateValues = { ...currentStateCleared, ...incomingStateValues }; + + Object.entries (nextStateValues).forEach (([key, value]) => this.sendStoredStateValue (key, value)); + + // N.B. worklet will handle the `parameters` part + super.sendFullStoredState (fullState); + } + + requestFullStoredState (callback) + { + // N.B. the worklet only handles the `parameters` part, so we patch the key-value state in here + super.requestFullStoredState (msg => callback ({ values: { ...this.cachedState }, ...msg })); + } + + getResourceAddress (path) + { + if (window.location.href.endsWith ("/")) + return window.location.href + path; + + return window.location.href + "/../" + path; + } + + async readResource (path) + { + return fetch (path); + } + + async readResourceAsAudioData (path) + { + const response = await this.readResource (path); + const buffer = await this.audioContext.decodeAudioData (await response.arrayBuffer()); + + let frames = []; + + for (let i = 0; i < buffer.length; ++i) + frames.push ([]); + + for (let chan = 0; chan < buffer.numberOfChannels; ++chan) + { + const src = buffer.getChannelData (chan); + + for (let i = 0; i < buffer.length; ++i) + frames[i].push (src[i]); + } + + return { frames, sampleRate: buffer.sampleRate }; + } + + //============================================================================== + /** @private */ + async startPatchWorker() + { + if (this.manifest.worker?.length > 0) + { + const module = await import (this.getResourceAddress (this.manifest.worker)); + module.default (this); + } + } +} diff --git a/assets/example_patches/Replicant/index.html b/assets/example_patches/Replicant/index.html new file mode 100644 index 00000000..b4e674ad --- /dev/null +++ b/assets/example_patches/Replicant/index.html @@ -0,0 +1,202 @@ + + + + Cmajor Patch + + + +
+
+
+
+
+
+ Replicant + Cmajor port of code by Mick Grierson + + - Click to Start - +
+
+ +
+
+ + + + + + diff --git a/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js b/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js index 8823dfb2..de79870f 100644 --- a/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js +++ b/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js @@ -248,10 +248,9 @@ class RingMod /** Stores frames for the input to endpoint "in" * * @param {Array} sourceChannelArrays - An array of channel arrays to read - * @param {number} numFramesToWrite - The number of frames to copy - * @param {number} sourceChannel - The source channel to copy from + * @param {number} numFramesToWrite - The number of frames to copy */ - setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite, sourceChannel) + setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite) { try { @@ -260,14 +259,32 @@ class RingMod let dest = 930208; - const channelsToCopy = Math.min (1, sourceChannelArrays.length - sourceChannel); + if (sourceChannelArrays[0].length === undefined) // If the input is a single channel + { + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + const sourceSample = sourceChannelArrays[frame] || 0; + + for (let channel = 0; channel < 1; ++channel) + this._pack_f32 (dest + 4 * channel, sourceSample); - for (let frame = 0; frame < numFramesToWrite; ++frame) + dest += 4; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - this._pack_f32 (dest + 4 * channel, sourceChannelArrays[sourceChannel + channel][frame]); + const numSourceChannels = sourceChannelArrays.length; + + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + { + const sourceSample = channel < numSourceChannels ? (sourceChannelArrays[channel][frame] || 0) : 0; + this._pack_f32 (dest + 4 * channel, sourceSample); + } - dest += 4; + dest += 4; + } } } catch (error) @@ -313,26 +330,55 @@ class RingMod /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 932256; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + source += 4; + } + } + else if (numDestChans > 1) { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; - source += 4; + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } + + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } } } diff --git a/assets/example_patches/RingMod/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/RingMod/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/RingMod/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/RingMod/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/Tremolo/cmaj_Tremolo.js b/assets/example_patches/Tremolo/cmaj_Tremolo.js index 38070b13..9b8463e0 100644 --- a/assets/example_patches/Tremolo/cmaj_Tremolo.js +++ b/assets/example_patches/Tremolo/cmaj_Tremolo.js @@ -304,10 +304,9 @@ class Tremolo /** Stores frames for the input to endpoint "in" * * @param {Array} sourceChannelArrays - An array of channel arrays to read - * @param {number} numFramesToWrite - The number of frames to copy - * @param {number} sourceChannel - The source channel to copy from + * @param {number} numFramesToWrite - The number of frames to copy */ - setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite, sourceChannel) + setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite) { try { @@ -316,14 +315,32 @@ class Tremolo let dest = 74704; - const channelsToCopy = Math.min (1, sourceChannelArrays.length - sourceChannel); - - for (let frame = 0; frame < numFramesToWrite; ++frame) + if (sourceChannelArrays[0].length === undefined) // If the input is a single channel { - for (let channel = 0; channel < channelsToCopy; ++channel) - this._pack_f32 (dest + 4 * channel, sourceChannelArrays[sourceChannel + channel][frame]); + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + const sourceSample = sourceChannelArrays[frame] || 0; + + for (let channel = 0; channel < 1; ++channel) + this._pack_f32 (dest + 4 * channel, sourceSample); - dest += 4; + dest += 4; + } + } + else + { + const numSourceChannels = sourceChannelArrays.length; + + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + { + const sourceSample = channel < numSourceChannels ? (sourceChannelArrays[channel][frame] || 0) : 0; + this._pack_f32 (dest + 4 * channel, sourceSample); + } + + dest += 4; + } } } catch (error) @@ -345,26 +362,55 @@ class Tremolo /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 76752; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + if (numDestChans < 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + else if (numDestChans > 1) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; + + for (let channel = 0; channel < 1; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 1; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 4; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 1; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 4; + source += 4; + } } } diff --git a/assets/example_patches/Tremolo/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/Tremolo/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/Tremolo/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/Tremolo/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js b/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js index 7f51ea81..acd714dc 100644 --- a/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js +++ b/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js @@ -437,10 +437,9 @@ class ZitaReverb /** Stores frames for the input to endpoint "in" * * @param {Array} sourceChannelArrays - An array of channel arrays to read - * @param {number} numFramesToWrite - The number of frames to copy - * @param {number} sourceChannel - The source channel to copy from + * @param {number} numFramesToWrite - The number of frames to copy */ - setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite, sourceChannel) + setInputStreamFrames_in (sourceChannelArrays, numFramesToWrite) { try { @@ -449,14 +448,32 @@ class ZitaReverb let dest = 1283248; - const channelsToCopy = Math.min (2, sourceChannelArrays.length - sourceChannel); - - for (let frame = 0; frame < numFramesToWrite; ++frame) + if (sourceChannelArrays[0].length === undefined) // If the input is a single channel { - for (let channel = 0; channel < channelsToCopy; ++channel) - this._pack_f32 (dest + 4 * channel, sourceChannelArrays[sourceChannel + channel][frame]); + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + const sourceSample = sourceChannelArrays[frame] || 0; + + for (let channel = 0; channel < 2; ++channel) + this._pack_f32 (dest + 4 * channel, sourceSample); - dest += 8; + dest += 8; + } + } + else + { + const numSourceChannels = sourceChannelArrays.length; + + for (let frame = 0; frame < numFramesToWrite; ++frame) + { + for (let channel = 0; channel < 2; ++channel) + { + const sourceSample = channel < numSourceChannels ? (sourceChannelArrays[channel][frame] || 0) : 0; + this._pack_f32 (dest + 4 * channel, sourceSample); + } + + dest += 8; + } } } catch (error) @@ -478,26 +495,55 @@ class ZitaReverb /** Copies frames from the output stream "out" into a destination array. * - * @param {Array} destChannelArrays - An array of arrays (one per channel) into - * which the samples will be copied + * @param {Array} destChannelArrays - An array of arrays (one per channel) into + * which the samples will be copied * @param {number} maxNumFramesToRead - The maximum number of frames to copy - * @param {number} destChannel - The channel to start writing from */ - getOutputFrames_out (destChannelArrays, maxNumFramesToRead, destChannel) + getOutputFrames_out (destChannelArrays, maxNumFramesToRead) { let source = 1287344; + let numDestChans = destChannelArrays.length; if (maxNumFramesToRead > 512) maxNumFramesToRead = 512; - const channelsToCopy = Math.min (2, destChannelArrays.length - destChannel); + if (numDestChans < 2) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 8; + } + } + else if (numDestChans > 2) + { + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + let lastSample; + + for (let channel = 0; channel < 2; ++channel) + { + lastSample = this.memoryDataView.getFloat32 (source + 4 * channel, true); + destChannelArrays[channel][frame] = lastSample; + } - for (let frame = 0; frame < maxNumFramesToRead; ++frame) + for (let channel = 2; channel < numDestChans; ++channel) + destChannelArrays[channel][frame] = lastSample; + + source += 8; + } + } + else { - for (let channel = 0; channel < channelsToCopy; ++channel) - destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < 2; ++channel) + destChannelArrays[channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); - source += 8; + source += 8; + } } } diff --git a/assets/example_patches/ZitaReverb/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/ZitaReverb/cmaj_api/cmaj_audio_worklet_helper.js index aae7dd7a..39f68f9b 100644 --- a/assets/example_patches/ZitaReverb/cmaj_api/cmaj_audio_worklet_helper.js +++ b/assets/example_patches/ZitaReverb/cmaj_api/cmaj_audio_worklet_helper.js @@ -104,27 +104,14 @@ function registerWorkletProcessor (workletName, WrapperClass) if (endpoints.length === 0) return () => {}; - var handlers = []; - var targetChannels = []; - - var channelCount = 0; - - for (const endpoint of endpoints) - { - const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${endpoint.endpointID}`]?.bind (wrapper); - if (! handleFrames) - return () => {}; - - handlers.push (handleFrames); - targetChannels.push (channelCount); - channelCount += endpoint.numAudioChannels; - } + // N.B. we just take the first for now (and do the same when creating the node). + // we can do better, and should probably align with something similar to what the patch player does + const first = endpoints[0]; + const handleFrames = wrapper[`${wrapperMethodNamePrefix}_${first.endpointID}`]?.bind (wrapper); + if (! handleFrames) + return () => {}; - return (channels, blockSize) => - { - for (var i = 0; i < handlers.length; i++) - handlers[i] (channels, blockSize, targetChannels[i]); - } + return (channels, blockSize) => handleFrames (channels, blockSize); } function makeInputStreamEndpointHandler (wrapper) @@ -527,11 +514,12 @@ export class AudioWorkletPatchConnection extends PatchConnection const audioInputEndpoints = this.inputEndpoints.filter (({ purpose }) => purpose === "audio in"); const audioOutputEndpoints = this.outputEndpoints.filter (({ purpose }) => purpose === "audio out"); - var inputChannelCount = 0; - var outputChannelCount = 0; + // N.B. we just take the first for now (and do the same in the processor too). + // we can do better, and should probably align with something similar to what the patch player does + const pickFirstEndpointChannelCount = (endpoints) => endpoints.length ? endpoints[0].numAudioChannels : 0; - audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); - audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + const inputChannelCount = pickFirstEndpointChannelCount (audioInputEndpoints); + const outputChannelCount = pickFirstEndpointChannelCount (audioOutputEndpoints); const hasInput = inputChannelCount > 0; const hasOutput = outputChannelCount > 0; diff --git a/docs/Examples/808.md b/docs/Examples/808.md index e52350b0..401b9c73 100644 --- a/docs/Examples/808.md +++ b/docs/Examples/808.md @@ -11,7 +11,6 @@ has_toc: false This is a Javascript/WebAssembly build of the "808" example patch. -Connect a MIDI keyboard to try it out. Click here to view the source code. diff --git a/docs/Examples/ElectricPiano.md b/docs/Examples/ElectricPiano.md index fe7dd01c..405350cf 100644 --- a/docs/Examples/ElectricPiano.md +++ b/docs/Examples/ElectricPiano.md @@ -1,6 +1,6 @@ --- layout: default -title: ElectricPiano +title: Electric Piano parent: Examples nav_order: 2 has_children: false @@ -11,7 +11,6 @@ has_toc: false This example patch is a model of an electric piano, implemented using additive synthesis. -Connect a MIDI keyboard to try it out. Click here to view the source code. diff --git a/docs/Examples/HelloWorld.md b/docs/Examples/HelloWorld.md index 84c537ec..e1d4fbd1 100644 --- a/docs/Examples/HelloWorld.md +++ b/docs/Examples/HelloWorld.md @@ -1,16 +1,15 @@ --- layout: default -title: HelloWorld +title: Hello World parent: Examples nav_order: 1 has_children: false has_toc: false --- -## HelloWorld - -This is a Javascript/WebAssembly build of the "HelloWorld" example patch. +## Hello World +This is a Javascript/WebAssembly build of the "Hello World" example patch. Click here to view the source code. diff --git a/docs/Examples/Piano.md b/docs/Examples/Piano.md index 8ef58710..7775fea7 100644 --- a/docs/Examples/Piano.md +++ b/docs/Examples/Piano.md @@ -11,7 +11,6 @@ has_toc: false This is a Javascript/WebAssembly build of the "Piano" example patch. -Connect a MIDI keyboard to try it out. Click here to view the source code. diff --git a/docs/Examples/PirkleFilters.md b/docs/Examples/PirkleFilters.md new file mode 100644 index 00000000..72ee8272 --- /dev/null +++ b/docs/Examples/PirkleFilters.md @@ -0,0 +1,28 @@ +--- +layout: default +title: Pirkle Filters +parent: Examples +nav_order: 9 +has_children: false +has_toc: false +--- + + +# Filters by Will Pirkle ported to CMajor + +These filters are ported from the C++ projects in Will Pirkle's books +and the application notes on his website + +- https://www.willpirkle.com/books/ +- https://www.willpirkle.com/app-notes/ + + +uncomment/comment the filters in the FilterTester graph to try the different types. You may also need to comment/uncomment parameters +depending on the filter + +Click here to view the source code. + + + diff --git a/docs/Examples/Replicant.md b/docs/Examples/Replicant.md new file mode 100644 index 00000000..1356a04a --- /dev/null +++ b/docs/Examples/Replicant.md @@ -0,0 +1,24 @@ +--- +layout: default +title: Replicant +parent: Examples +nav_order: 10 +has_children: false +has_toc: false +--- + +# cmajor_replicant + +[Cmajor](https://cmajor.dev) port of [maximillian "replicant" example](https://github.com/micknoise/Maximilian/blob/master/cpp/commandline/maximilian_examples/16.Replicant/main.cpp) + +thanks to Mick Grierson (and Vangelis) for the original... + + + + +Click here to view the source code. + + + diff --git a/docs/Examples/RingMod.md b/docs/Examples/RingMod.md index 0fe4fefa..af138896 100644 --- a/docs/Examples/RingMod.md +++ b/docs/Examples/RingMod.md @@ -1,6 +1,6 @@ --- layout: default -title: RingMod +title: Ring Modulator parent: Examples nav_order: 6 has_children: false @@ -34,7 +34,6 @@ This contains a block diagram and a branch equation we can use to model our exam We use a sine wave as the modulating signal (Vin) and audioIn from the user (Vc). Two distinct diode blocks are created, each utilising a phase invert signal that is combined with the distorted signal. Both diode blocks are then summed back together to complete the ring modulation effect. - Click here to view the source code.