diff --git a/assets/example_patches/808/cmaj_808.js b/assets/example_patches/808/cmaj_808.js index b26beae7..5e25d240 100644 --- a/assets/example_patches/808/cmaj_808.js +++ b/assets/example_patches/808/cmaj_808.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch '808.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/CompuFart/README.md b/assets/example_patches/CompuFart/README.md new file mode 100644 index 00000000..8b1b7916 --- /dev/null +++ b/assets/example_patches/CompuFart/README.md @@ -0,0 +1,23 @@ +### Auto-generated HTML & Javascript for Cmajor Patch "CompuFart" + +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_CompuFart.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/CompuFart/cmaj_CompuFart.js b/assets/example_patches/CompuFart/cmaj_CompuFart.js new file mode 100644 index 00000000..645ced2c --- /dev/null +++ b/assets/example_patches/CompuFart/cmaj_CompuFart.js @@ -0,0 +1,855 @@ +//============================================================================== +// +// This file contains a Javascript/Webassembly/WebAudio export of the Cmajor +// patch 'CompuFartSynth.cmajorpatch'. +// +// This file was auto-generated by the Cmajor toolkit v1.0.2381 +// +// 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.audolon.compufart", + "version": "0.1.01", + "name": "CompuFart", + "description": "CompuFart Fart Synthesizer", + "category": "generator", + "manufacturer": "Audolon", + "isInstrument": true, + "source": [ + "Audolon.cmajor", + "CompuFart.cmajor", + "CompuFartSynth.cmajor", + "Delay.cmajor", + "Envelopes.cmajor", + "RePoot.cmajor", + "Utils.cmajor" + ] +}; + +/** Returns the patch's output endpoint list */ +export function getOutputEndpoints() { return CompuFartSynth.prototype.getOutputEndpoints(); } + +/** Returns the patch's input endpoint list */ +export function getInputEndpoints() { return CompuFartSynth.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 (CompuFartSynth, audioContext, workletName, Date.now() & 0x7fffffff); + return connection; +} + +/*********************************************************************************** + * + * A Javascript/Webassembly implementation of the Cmajor processor 'Audolon::CompuFart::CompuFartSynth'. + * + * 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 CompuFartSynth +{ + /** 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 }, 77712); + + 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 (77712, 95520, numFrames); + else + this._advance = () => exports.advanceOneFrame (77712, 95520); + + exports.initialise?.(77712, 97568, 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, 95520, 95520 + 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_audio", + "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": "in_midi", + "endpointType": "event", + "dataType": { + "type": "object", + "class": "Message", + "members": { + "message": { + "type": "int32" + } + } + }, + "purpose": "midi in" + }, + { + "endpointID": "in_pinch", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pinch", + "min": 0.0, + "max": 1, + "init": 0.5, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_cheek", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Cheek", + "min": 0.0, + "max": 1, + "init": 0.5, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_strain", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Strain", + "min": 0.0, + "max": 1, + "init": 0.699999988079071, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_strainIntensity", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Strain Intensity", + "min": 0.0, + "max": 1, + "init": 0.5, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pressureEnvAttackTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pressure Attack", + "min": 0.0, + "max": 1, + "init": 0.004999999888241291, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pressureEnvDecayTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pressure Decay", + "min": 0.0, + "max": 5, + "init": 0.019999999552965165, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pressureEnvSustainLevel", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pressure Sustain", + "min": 0.0, + "max": 1, + "init": 0.800000011920929, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pressureEnvReleaseTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pressure Release", + "min": 0.0, + "max": 5, + "init": 0.8500000238418579, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchEnvAmount", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Env Amount", + "min": -3, + "max": 3, + "init": 2, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchEnvAttackTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Attack", + "min": 0.0, + "max": 1, + "init": 0.004999999888241291, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchEnvDecayTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Decay", + "min": 0.0, + "max": 5, + "init": 0.30000001192092898, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchEnvSustainLevel", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Sustain", + "min": 0.0, + "max": 1, + "init": 0.0, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchEnvReleaseTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Release", + "min": 0.0, + "max": 5, + "init": 0.25, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_reverbMode", + "endpointType": "event", + "dataType": { + "type": "int32" + }, + "annotation": { + "name": "Reverb", + "min": 0, + "max": 2, + "init": 1, + "text": "None|Toilet Bowl|Church Pew", + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchGlideTime", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Glide", + "min": 0.0, + "max": 1, + "init": 0.10000000149011612, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_pitchBendRangeSemitones", + "endpointType": "event", + "dataType": { + "type": "float32" + }, + "annotation": { + "name": "Pitch Bend Range", + "min": 0.0, + "max": 48, + "init": 48, + "step": 0.0010000000474974514, + "automatable": true + }, + "purpose": "parameter" + }, + { + "endpointID": "in_modWheelOn", + "endpointType": "event", + "dataType": { + "type": "bool" + }, + "annotation": { + "name": "Mod Wheel", + "boolean": true, + "init": false, + "automatable": true + }, + "purpose": "parameter" + } + ]; + } + + /** Sends an event of type `std::midi::Message` to endpoint "in_midi". + * @param {Object} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_midi (eventValue) + { + this._pack_o1_Message_message_i32 (97568, eventValue); + this.instance.exports._sendEvent_in_midi (77712, 97568); + } + + /** Sends an event of type `float32` to endpoint "in_pinch". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pinch (eventValue) + { + this.instance.exports._sendEvent_in_pinch (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_cheek". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_cheek (eventValue) + { + this.instance.exports._sendEvent_in_cheek (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_strain". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_strain (eventValue) + { + this.instance.exports._sendEvent_in_strain (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_strainIntensity". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_strainIntensity (eventValue) + { + this.instance.exports._sendEvent_in_strainIntensity (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pressureEnvAttackTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pressureEnvAttackTime (eventValue) + { + this.instance.exports._sendEvent_in_pressureEnvAttackTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pressureEnvDecayTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pressureEnvDecayTime (eventValue) + { + this.instance.exports._sendEvent_in_pressureEnvDecayTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pressureEnvSustainLevel". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pressureEnvSustainLevel (eventValue) + { + this.instance.exports._sendEvent_in_pressureEnvSustainLevel (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pressureEnvReleaseTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pressureEnvReleaseTime (eventValue) + { + this.instance.exports._sendEvent_in_pressureEnvReleaseTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchEnvAmount". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchEnvAmount (eventValue) + { + this.instance.exports._sendEvent_in_pitchEnvAmount (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchEnvAttackTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchEnvAttackTime (eventValue) + { + this.instance.exports._sendEvent_in_pitchEnvAttackTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchEnvDecayTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchEnvDecayTime (eventValue) + { + this.instance.exports._sendEvent_in_pitchEnvDecayTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchEnvSustainLevel". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchEnvSustainLevel (eventValue) + { + this.instance.exports._sendEvent_in_pitchEnvSustainLevel (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchEnvReleaseTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchEnvReleaseTime (eventValue) + { + this.instance.exports._sendEvent_in_pitchEnvReleaseTime (77712, eventValue); + } + + /** Sends an event of type `int32` to endpoint "in_reverbMode". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_reverbMode (eventValue) + { + this.instance.exports._sendEvent_in_reverbMode (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchGlideTime". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchGlideTime (eventValue) + { + this.instance.exports._sendEvent_in_pitchGlideTime (77712, eventValue); + } + + /** Sends an event of type `float32` to endpoint "in_pitchBendRangeSemitones". + * @param {number} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_pitchBendRangeSemitones (eventValue) + { + this.instance.exports._sendEvent_in_pitchBendRangeSemitones (77712, eventValue); + } + + /** Sends an event of type `bool` to endpoint "in_modWheelOn". + * @param {boolean} eventValue - The event to be added to the queue for this endpoint. + */ + sendInputEvent_in_modWheelOn (eventValue) + { + this.instance.exports._sendEvent_in_modWheelOn (77712, eventValue); + } + + /** Returns a frame from the output stream "out_audio" + * + * @param {number} frameIndex - the index of the frame to fetch + */ + getOutputFrame_out_audio (frameIndex) + { + return this.memoryDataView.getFloat32 (95520 + frameIndex * 4, true); + } + + /** Copies frames from the output stream "out_audio" 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 + * @param {number} destChannel - The channel to start writing from + */ + getOutputFrames_out_audio (destChannelArrays, maxNumFramesToRead, destChannel) + { + let source = 95520; + + if (maxNumFramesToRead > 512) + maxNumFramesToRead = 512; + + const channelsToCopy = Math.min (1, destChannelArrays.length - destChannel); + + for (let frame = 0; frame < maxNumFramesToRead; ++frame) + { + for (let channel = 0; channel < channelsToCopy; ++channel) + destChannelArrays[destChannel + channel][frame] = this.memoryDataView.getFloat32 (source + 4 * channel, true); + + source += 4; + } + } + + //============================================================================== + // Code beyond this point is private internal implementation detail + + //============================================================================== + /** @access private */ + _pack_o1_Message_message_i32 (address, newValue) + { + this.memoryDataView.setInt32 (address, newValue.message, true); + } + + /** @access private */ + _getWasmBytes() + { + return new Uint8Array([0,97,115,109,1,0,0,0,1,190,128,128,128,0,8,96,2,127,127,0,96,2,125,125,1,125,96,2,125,127,1,125,96,2,127,125,0,96,16,127,127,125,125,125,125,125,125,125,125,125,125,125,125,125,125, + 0,96,3,127,127,127,1,127,96,4,127,127,127,124,0,96,3,127,127,127,0,2,165,128,128,128,0,2,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,6,109,101,109,115,101, + 116,0,5,3,152,128,128,128,0,23,0,1,2,3,3,3,3,3,3,3,3,3,3,3,3,3,0,4,3,3,0,6,7,7,217,132,128,128,0,20,18,95,115,101,110,100,69,118,101,110,116,95,105,110,95,109,105,100,105,0,1,19,95,115,101,110,100,69,118, + 101,110,116,95,105,110,95,112,105,110,99,104,0,4,19,95,115,101,110,100,69,118,101,110,116,95,105,110,95,99,104,101,101,107,0,5,20,95,115,101,110,100,69,118,101,110,116,95,105,110,95,115,116,114,97,105, + 110,0,6,29,95,115,101,110,100,69,118,101,110,116,95,105,110,95,115,116,114,97,105,110,73,110,116,101,110,115,105,116,121,0,7,35,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117, + 114,101,69,110,118,65,116,116,97,99,107,84,105,109,101,0,8,34,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110,118,68,101,99,97,121,84,105,109,101,0,9,37,95,115, + 101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110,118,83,117,115,116,97,105,110,76,101,118,101,108,0,10,36,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,114, + 101,115,115,117,114,101,69,110,118,82,101,108,101,97,115,101,84,105,109,101,0,11,28,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,65,109,111,117,110,116,0,12,32,95,115, + 101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,65,116,116,97,99,107,84,105,109,101,0,13,31,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118, + 68,101,99,97,121,84,105,109,101,0,14,34,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,83,117,115,116,97,105,110,76,101,118,101,108,0,15,33,95,115,101,110,100,69,118, + 101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,82,101,108,101,97,115,101,84,105,109,101,0,16,24,95,115,101,110,100,69,118,101,110,116,95,105,110,95,114,101,118,101,114,98,77,111,100,101,0,17,28, + 95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,71,108,105,100,101,84,105,109,101,0,19,37,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,66,101,110,100,82, + 97,110,103,101,83,101,109,105,116,111,110,101,115,0,20,24,95,115,101,110,100,69,118,101,110,116,95,105,110,95,109,111,100,87,104,101,101,108,79,110,0,21,10,105,110,105,116,105,97,108,105,115,101,0,22,12, + 97,100,118,97,110,99,101,66,108,111,99,107,0,23,12,129,128,128,128,0,1,10,151,209,128,128,0,23,170,7,4,1,127,4,125,3,127,1,126,2,64,32,1,40,2,0,34,1,65,128,128,192,7,113,34,2,65,128,128,192,4,71,13,0,32, + 1,65,255,1,113,69,13,0,67,0,0,0,64,32,1,65,8,118,65,255,0,113,178,67,0,0,138,194,146,67,171,170,170,61,148,16,130,128,128,128,0,33,3,32,0,65,204,0,106,32,1,65,255,0,113,178,67,4,2,1,60,148,34,4,56,2,0, + 32,0,65,176,1,106,32,3,67,0,0,220,67,148,34,3,56,2,0,32,0,65,172,1,106,34,1,42,2,0,33,5,32,1,32,3,56,2,0,32,0,65,200,0,106,34,1,42,2,0,33,6,32,1,32,4,56,2,0,32,0,65,184,1,106,34,1,40,2,0,33,2,32,1,32,0, + 65,168,1,106,40,2,0,34,7,65,1,32,7,65,1,74,27,34,7,54,2,0,32,0,65,212,0,106,34,1,40,2,0,33,8,32,1,32,0,65,24,106,40,2,0,34,9,65,1,32,9,65,1,74,27,34,9,54,2,0,32,0,65,180,1,106,34,1,32,3,32,5,32,1,42,2, + 0,32,2,178,148,147,147,32,7,178,149,56,2,0,32,0,65,208,0,106,34,1,32,4,32,6,32,1,42,2,0,32,8,178,148,147,147,32,9,178,149,56,2,0,2,64,32,0,65,28,106,40,2,0,13,0,32,0,65,244,0,106,65,1,54,2,0,32,0,65,36, + 106,65,1,54,2,0,11,32,0,32,0,40,2,28,65,1,106,54,2,28,15,11,2,64,2,64,2,64,32,2,65,128,128,128,4,70,13,0,32,1,65,255,129,192,7,113,65,128,128,192,4,71,13,1,11,32,0,65,28,106,40,2,0,34,1,65,1,72,13,1,32, + 0,32,1,65,127,106,34,1,54,2,28,32,1,13,1,2,64,32,0,65,36,106,40,2,0,69,13,0,32,0,65,4,54,2,36,11,32,0,65,244,0,106,40,2,0,69,13,1,32,0,65,4,54,2,116,15,11,2,64,32,2,65,128,128,192,5,70,13,0,32,2,65,128, + 128,128,7,71,13,1,32,0,65,200,1,106,34,2,40,2,0,33,7,32,2,32,0,65,24,106,40,2,0,34,8,65,1,32,8,65,1,74,27,34,8,54,2,0,32,0,65,208,1,106,32,1,65,7,116,65,128,255,0,113,32,1,65,8,118,65,255,0,113,114,65, + 128,64,106,178,67,0,0,192,59,148,67,0,0,64,66,149,34,4,56,2,0,32,0,65,192,1,106,32,4,32,0,65,204,1,106,42,2,0,148,34,4,56,2,0,32,0,65,188,1,106,34,1,42,2,0,33,3,32,1,32,4,56,2,0,32,0,65,196,1,106,34,0, + 32,4,32,3,32,0,42,2,0,32,7,178,148,147,147,32,8,178,149,56,2,0,15,11,2,64,32,1,65,8,118,65,255,0,113,34,2,65,251,0,70,13,0,32,2,65,1,71,13,1,32,0,65,236,0,106,32,1,65,255,0,113,178,67,4,2,1,60,148,34,4, + 56,2,0,32,0,65,220,0,106,32,4,67,0,0,128,63,32,0,65,232,0,106,45,0,0,27,34,4,56,2,0,32,0,65,228,0,106,34,1,40,2,0,33,2,32,1,32,0,65,24,106,40,2,0,34,7,65,1,32,7,65,1,74,27,34,7,54,2,0,32,0,65,216,0,106, + 34,1,42,2,0,33,3,32,1,32,4,56,2,0,32,0,65,224,0,106,34,0,32,4,32,3,32,0,42,2,0,32,2,178,148,147,147,32,7,178,149,56,2,0,15,11,32,0,65,200,0,106,66,0,55,2,0,32,0,65,28,106,65,0,54,2,0,32,0,65,208,0,106, + 66,0,55,2,0,32,0,65,208,2,106,65,1,58,0,0,32,0,65,132,3,106,66,0,55,2,0,32,0,65,140,3,106,66,0,55,2,0,32,0,65,148,3,106,66,0,55,2,0,32,0,65,164,3,106,66,0,55,2,0,32,0,65,220,2,106,32,0,65,196,2,106,41, + 2,0,66,32,137,34,10,55,2,0,32,0,65,228,2,106,32,10,55,2,0,32,0,65,236,2,106,32,10,55,2,0,11,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,131,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,36, + 0,32,0,65,164,2,106,32,1,67,0,0,64,63,148,67,0,0,128,62,146,34,1,32,1,148,67,136,69,200,77,148,56,2,0,11,56,0,32,0,65,204,2,106,32,1,32,1,148,67,82,73,157,58,148,67,166,155,196,58,146,56,2,0,32,0,65,200, + 2,106,67,0,0,128,63,32,1,147,34,1,32,1,148,67,82,73,29,186,148,56,2,0,11,51,0,32,0,65,224,1,106,32,1,67,51,51,179,62,148,34,1,67,51,51,179,62,32,1,67,51,51,179,62,93,27,34,1,67,0,0,0,0,32,1,67,0,0,0,0, + 94,27,56,2,0,11,110,0,32,0,65,232,1,106,32,1,67,0,0,128,63,32,1,67,0,0,128,63,93,27,34,1,67,0,0,0,0,32,1,67,0,0,0,0,94,27,67,0,64,28,70,148,67,0,0,200,66,146,67,219,15,201,192,148,65,0,43,3,128,128,128, + 128,0,182,149,67,0,0,128,63,146,34,1,67,119,190,127,63,32,1,67,119,190,127,63,93,27,34,1,67,0,0,0,0,32,1,67,0,0,0,0,94,27,56,2,0,11,33,0,32,0,65,52,106,67,0,0,128,63,32,0,65,32,106,42,2,0,32,1,148,67,0, + 0,128,63,151,149,56,2,0,11,95,1,1,127,2,64,2,64,32,0,65,32,106,42,2,0,32,1,148,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,44,106,32,2,65,1,32,2,65,1,74, + 27,34,2,54,2,0,32,0,65,56,106,67,0,0,128,63,32,0,65,192,0,106,42,2,0,147,32,2,178,149,56,2,0,11,91,0,32,0,65,192,0,106,32,1,67,0,0,128,63,32,1,67,0,0,128,63,93,27,34,1,67,0,0,0,0,32,1,67,0,0,0,0,94,27, + 34,1,56,2,0,32,0,65,60,106,32,1,32,0,65,48,106,40,2,0,178,149,56,2,0,32,0,65,56,106,67,0,0,128,63,32,1,147,32,0,65,44,106,40,2,0,178,149,56,2,0,11,89,1,1,127,2,64,2,64,32,0,65,32,106,42,2,0,32,1,148,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,48,106,32,2,65,1,32,2,65,1,74,27,34,2,54,2,0,32,0,65,60,106,32,0,65,192,0,106,42,2,0,32,2,178,149,56,2,0,11,107, + 3,1,127,1,125,2,127,32,0,65,156,1,106,32,1,56,2,0,32,0,65,152,1,106,34,2,42,2,0,33,3,32,2,32,1,56,2,0,32,0,65,164,1,106,34,2,40,2,0,33,4,32,2,32,0,65,24,106,40,2,0,34,5,65,1,32,5,65,1,74,27,34,5,54,2,0, + 32,0,65,160,1,106,34,0,32,1,32,3,32,0,42,2,0,32,4,178,148,147,147,32,5,178,149,56,2,0,11,35,0,32,0,65,132,1,106,67,0,0,128,63,32,0,65,240,0,106,42,2,0,32,1,148,67,0,0,128,63,151,149,56,2,0,11,98,1,1,127, + 2,64,2,64,32,0,65,240,0,106,42,2,0,32,1,148,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,252,0,106,32,2,65,1,32,2,65,1,74,27,34,2,54,2,0,32,0,65,136,1,106, + 67,0,0,128,63,32,0,65,144,1,106,42,2,0,147,32,2,178,149,56,2,0,11,95,0,32,0,65,144,1,106,32,1,67,0,0,128,63,32,1,67,0,0,128,63,93,27,34,1,67,0,0,0,0,32,1,67,0,0,0,0,94,27,34,1,56,2,0,32,0,65,140,1,106, + 32,1,32,0,65,128,1,106,40,2,0,178,149,56,2,0,32,0,65,136,1,106,67,0,0,128,63,32,1,147,32,0,65,252,0,106,40,2,0,178,149,56,2,0,11,92,1,1,127,2,64,2,64,32,0,65,240,0,106,42,2,0,32,1,148,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,128,1,106,32,2,65,1,32,2,65,1,74,27,34,2,54,2,0,32,0,65,140,1,106,32,0,65,144,1,106,42,2,0,32,2,178,149,56,2,0,11,165,2,2,1,127, + 14,125,32,0,65,176,3,106,34,2,65,1,32,1,65,2,70,65,1,116,32,1,65,1,70,27,34,1,54,2,0,67,205,204,12,63,33,3,67,82,120,176,68,33,4,67,208,42,43,63,33,5,67,166,48,65,59,33,6,67,140,102,205,190,33,7,67,211, + 169,142,57,33,8,67,29,4,149,62,33,9,67,17,127,160,58,33,10,67,102,102,102,63,33,11,67,154,153,25,63,33,12,67,51,51,51,63,33,13,67,72,84,178,58,33,14,67,88,198,130,58,33,15,67,184,197,237,57,33,16,65,3, + 33,0,2,64,2,64,2,64,32,1,14,2,2,1,0,11,67,154,153,25,63,33,13,67,115,120,48,68,33,4,67,171,95,73,62,33,5,67,122,83,165,60,33,6,67,69,18,157,190,33,7,67,68,38,144,59,33,8,67,91,67,57,62,33,9,67,250,211, + 103,59,33,10,67,0,0,0,0,33,11,67,0,0,0,63,33,12,67,111,18,131,58,33,14,67,101,45,13,60,33,15,67,0,13,208,59,33,16,65,2,33,0,67,154,153,25,63,33,3,11,32,2,32,0,32,16,32,15,32,14,32,13,32,12,32,11,32,10, + 32,9,32,8,32,7,32,6,32,5,32,4,32,3,16,146,128,128,128,0,11,11,165,7,1,3,127,32,0,32,1,54,2,156,135,1,32,0,32,5,56,2,172,135,1,32,0,65,176,135,1,106,32,6,56,2,0,32,0,65,180,135,1,106,32,7,56,2,0,2,64,2, + 64,32,2,65,0,43,3,128,128,128,128,0,182,34,5,148,34,6,67,0,0,0,191,67,0,0,0,63,32,6,67,0,0,0,0,93,27,146,34,6,139,67,0,0,0,79,93,69,13,0,32,6,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,32,1,54, + 2,160,135,1,2,64,2,64,32,3,32,5,148,34,6,67,0,0,0,191,67,0,0,0,63,32,6,67,0,0,0,0,93,27,146,34,6,139,67,0,0,0,79,93,69,13,0,32,6,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,65,164,135,1,106,32, + 1,54,2,0,2,64,2,64,32,4,32,5,148,34,6,67,0,0,0,191,67,0,0,0,63,32,6,67,0,0,0,0,93,27,146,34,6,139,67,0,0,0,79,93,69,13,0,32,6,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,65,168,135,1,106,32,1, + 54,2,0,2,64,2,64,32,8,32,5,148,34,5,67,0,0,0,191,67,0,0,0,63,32,5,67,0,0,0,0,93,27,146,34,5,139,67,0,0,0,79,93,69,13,0,32,5,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,65,132,15,106,32,1,65,224, + 3,32,1,65,224,3,72,27,34,1,65,1,32,1,65,1,74,27,54,2,0,32,0,65,4,106,65,0,65,128,15,16,128,128,128,128,0,33,16,32,0,32,9,67,114,249,127,63,32,9,67,114,249,127,63,93,27,34,5,67,114,249,127,191,32,5,67,114, + 249,127,191,94,27,56,2,200,135,1,32,0,65,136,15,106,34,17,65,0,54,2,0,2,64,2,64,32,10,65,0,43,3,128,128,128,128,0,182,148,34,5,67,0,0,0,191,67,0,0,0,63,32,5,67,0,0,0,0,93,27,146,34,5,139,67,0,0,0,79,93, + 69,13,0,32,5,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,65,140,30,106,32,1,65,224,3,32,1,65,224,3,72,27,34,1,65,1,32,1,65,1,74,27,54,2,0,32,0,65,140,15,106,65,0,65,128,15,16,128,128,128,128,0, + 26,32,0,32,11,67,114,249,127,63,32,11,67,114,249,127,63,93,27,34,5,67,114,249,127,191,32,5,67,114,249,127,191,94,27,56,2,204,135,1,32,0,65,144,30,106,34,18,65,0,54,2,0,2,64,2,64,32,12,65,0,43,3,128,128, + 128,128,0,182,148,34,5,67,0,0,0,191,67,0,0,0,63,32,5,67,0,0,0,0,93,27,146,34,5,139,67,0,0,0,79,93,69,13,0,32,5,168,33,1,12,1,11,65,128,128,128,128,120,33,1,11,32,0,65,148,233,0,106,32,1,65,224,18,32,1, + 65,224,18,72,27,34,1,65,1,32,1,65,1,74,27,54,2,0,32,0,65,148,30,106,65,0,65,128,203,0,16,128,128,128,128,0,26,32,0,32,13,67,114,249,127,63,32,13,67,114,249,127,63,93,27,34,5,67,114,249,127,191,32,5,67, + 114,249,127,191,94,27,56,2,208,135,1,32,0,65,152,233,0,106,34,1,65,0,54,2,0,32,0,32,15,56,2,212,135,1,32,0,32,14,67,219,15,201,64,148,65,0,43,3,128,128,128,128,0,182,149,67,0,0,128,191,146,67,0,0,0,0,150, + 34,5,67,114,249,127,63,32,5,67,114,249,127,63,93,27,34,5,67,114,249,127,191,32,5,67,114,249,127,191,94,27,34,5,56,2,192,135,1,32,0,32,5,67,0,0,128,63,146,56,2,196,135,1,32,16,65,0,65,128,15,16,128,128, + 128,128,0,26,32,0,65,0,54,2,188,135,1,32,17,65,0,65,132,15,16,128,128,128,128,0,26,32,18,65,0,65,132,203,0,16,128,128,128,128,0,26,32,1,65,0,65,132,30,16,128,128,128,128,0,26,11,75,2,1,124,1,127,2,64,2, + 64,65,0,43,3,128,128,128,128,0,32,1,187,162,34,2,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32,2,170,33,3,12,1,11,65,128,128,128,128,120,33,3,11,32,0,65,168,1,106,32,3,65,1,32,3,65,1,74,27,54,2,0,11,130,1,3, + 1,127,1,125,2,127,32,0,65,204,1,106,32,1,56,2,0,32,0,65,192,1,106,32,0,65,208,1,106,42,2,0,32,1,148,34,1,56,2,0,32,0,65,188,1,106,34,2,42,2,0,33,3,32,2,32,1,56,2,0,32,0,65,200,1,106,34,2,40,2,0,33,4,32, + 2,32,0,65,24,106,40,2,0,34,5,65,1,32,5,65,1,74,27,34,5,54,2,0,32,0,65,196,1,106,34,0,32,1,32,3,32,0,42,2,0,32,4,178,148,147,147,32,5,178,149,56,2,0,11,147,1,2,2,125,2,127,32,0,65,232,0,106,32,1,65,1,113, + 34,1,58,0,0,67,0,0,128,63,33,2,2,64,32,1,69,13,0,32,0,65,236,0,106,42,2,0,33,2,11,32,0,65,220,0,106,32,2,56,2,0,32,0,65,216,0,106,34,1,42,2,0,33,3,32,1,32,2,56,2,0,32,0,65,228,0,106,34,1,40,2,0,33,4,32, + 1,32,0,65,24,106,40,2,0,34,5,65,1,32,5,65,1,74,27,34,5,54,2,0,32,0,65,224,0,106,34,0,32,2,32,3,32,0,42,2,0,32,4,178,148,147,147,32,5,178,149,56,2,0,11,245,9,7,1,125,1,124,2,127,1,125,1,127,1,125,1,126, + 65,0,32,3,57,3,128,128,128,128,0,32,0,65,32,106,32,3,182,34,4,56,2,0,32,0,65,36,106,66,0,55,2,0,32,0,65,192,0,106,65,205,153,179,250,3,54,2,0,32,0,65,196,0,106,65,0,58,0,0,32,0,65,240,0,106,32,4,56,2,0, + 32,0,65,244,0,106,66,0,55,2,0,32,0,65,144,1,106,65,205,153,179,250,3,54,2,0,32,0,65,148,1,106,65,0,58,0,0,2,64,2,64,32,3,68,252,169,241,210,77,98,64,63,162,34,5,153,68,0,0,0,0,0,0,224,65,99,69,13,0,32, + 5,170,33,6,12,1,11,65,128,128,128,128,120,33,6,11,32,0,65,24,106,34,7,32,6,65,1,32,6,65,1,74,27,54,2,0,32,0,65,52,106,67,0,0,128,63,32,4,67,10,215,35,60,148,67,0,0,128,63,151,149,34,8,56,2,0,32,0,65,132, + 1,106,32,8,56,2,0,2,64,2,64,32,4,67,10,215,163,60,148,34,8,139,67,0,0,0,79,93,69,13,0,32,8,168,33,6,12,1,11,65,128,128,128,128,120,33,6,11,32,0,65,44,106,32,6,65,1,32,6,65,1,74,27,34,6,54,2,0,2,64,2,64, + 32,4,67,0,0,0,63,148,34,8,139,67,0,0,0,79,93,69,13,0,32,8,168,33,9,12,1,11,65,128,128,128,128,120,33,9,11,32,0,65,48,106,32,9,65,1,32,9,65,1,74,27,34,9,54,2,0,32,0,65,252,0,106,32,6,54,2,0,32,0,65,128, + 1,106,32,9,54,2,0,32,0,65,56,106,67,204,204,76,62,32,6,178,149,34,8,56,2,0,32,0,65,60,106,67,205,204,76,63,32,9,178,149,34,10,56,2,0,32,0,65,136,1,106,32,8,56,2,0,32,0,65,140,1,106,32,10,56,2,0,32,0,65, + 232,0,106,65,0,58,0,0,32,0,65,28,106,65,0,54,2,0,32,0,65,208,0,106,66,0,55,2,0,32,0,65,200,0,106,66,0,55,2,0,32,0,65,236,0,106,65,128,128,128,252,3,54,2,0,32,0,65,160,1,106,66,0,55,2,0,32,0,65,152,1,106, + 66,0,55,2,0,32,0,65,172,1,106,66,128,128,160,150,132,128,128,228,194,0,55,2,0,32,0,65,196,1,106,66,0,55,2,0,32,0,65,188,1,106,66,0,55,2,0,32,0,65,180,1,106,66,0,55,2,0,32,0,65,204,1,106,66,128,128,128, + 138,4,55,2,0,32,0,65,168,1,106,32,7,40,2,0,54,2,0,32,1,32,1,40,2,0,65,1,106,34,6,54,2,0,65,0,32,3,57,3,128,128,128,128,0,32,0,65,236,1,106,32,6,54,2,0,32,0,65,232,1,106,65,0,54,2,0,32,0,65,224,1,106,66, + 0,55,2,0,32,0,65,164,2,106,65,166,232,216,232,4,54,2,0,32,0,65,156,2,106,66,128,128,200,150,148,223,192,202,207,0,55,2,0,32,0,65,136,2,106,65,128,128,128,248,3,54,2,0,32,0,65,128,2,106,66,158,233,156,206, + 131,128,128,160,192,0,55,2,0,32,0,65,252,1,106,67,0,0,128,63,32,4,149,56,2,0,32,0,65,248,1,106,32,4,56,2,0,32,0,65,144,2,106,67,42,105,35,61,145,34,4,67,0,0,64,64,149,34,8,56,2,0,32,0,65,140,2,106,32,8, + 56,2,0,32,0,65,148,2,106,32,4,67,0,0,0,63,149,56,2,0,32,0,65,168,2,106,34,1,66,194,192,149,223,243,205,196,129,59,55,2,0,32,0,65,176,2,106,66,239,164,140,212,251,205,196,193,186,127,55,2,0,32,0,65,184, + 2,106,66,128,128,128,128,240,205,196,193,59,55,2,0,32,0,65,196,2,106,65,151,238,198,206,123,54,2,0,32,0,65,152,2,106,65,0,54,2,0,32,0,65,216,1,106,32,6,172,66,197,0,124,34,11,66,32,136,32,11,132,66,255, + 255,255,255,7,131,55,3,0,32,0,65,128,3,106,65,128,128,168,157,4,54,2,0,32,0,65,248,2,106,66,154,179,230,252,179,142,204,205,55,55,2,0,32,0,65,208,2,106,65,1,58,0,0,32,0,65,200,2,106,66,239,164,140,220, + 251,205,196,193,59,55,2,0,32,0,65,192,2,106,65,151,238,198,202,3,54,2,0,32,0,65,220,2,106,66,239,164,140,212,251,205,196,193,186,127,55,2,0,32,0,65,228,2,106,66,239,164,140,212,251,205,196,193,186,127, + 55,2,0,32,0,65,236,2,106,66,239,164,140,212,251,205,196,193,186,127,55,2,0,32,0,65,244,2,106,65,129,192,253,192,4,54,2,0,32,0,65,216,2,106,65,138,174,143,217,3,54,2,0,32,0,65,132,3,106,66,0,55,2,0,32,0, + 65,140,3,106,66,0,55,2,0,32,0,65,148,3,106,66,0,55,2,0,32,0,65,164,3,106,66,0,55,2,0,65,0,32,3,57,3,128,128,128,128,0,32,0,65,176,3,106,34,6,65,1,54,2,0,32,0,65,212,2,106,32,1,42,2,0,34,4,32,4,146,67,111, + 18,131,186,148,56,2,0,32,6,65,3,67,184,197,237,57,67,88,198,130,58,67,72,84,178,58,67,51,51,51,63,67,154,153,25,63,67,102,102,102,63,67,17,127,160,58,67,29,4,149,62,67,211,169,142,57,67,140,102,205,190, + 67,166,48,65,59,67,208,42,43,63,67,82,120,176,68,67,205,204,12,63,16,146,128,128,128,0,11,172,29,10,5,127,2,125,1,127,3,125,1,126,2,125,1,124,2,127,1,124,2,127,2,64,32,0,40,2,0,32,2,70,13,0,32,0,65,188, + 18,106,33,3,32,0,65,180,3,106,33,4,32,0,65,196,33,106,33,5,32,0,65,148,2,106,33,6,32,0,65,144,2,106,33,7,3,64,67,0,0,0,0,33,8,67,0,0,0,0,33,9,2,64,32,0,40,2,212,1,65,127,70,13,0,2,64,2,64,32,0,40,2,164, + 1,34,10,65,1,72,13,0,32,0,32,10,65,127,106,34,10,54,2,164,1,32,0,42,2,152,1,32,0,42,2,160,1,32,10,178,148,147,33,8,12,1,11,32,0,42,2,152,1,33,8,11,2,64,2,64,2,64,2,64,2,64,32,0,40,2,116,65,127,106,14,4, + 0,1,4,2,4,11,32,0,32,0,42,2,120,32,0,42,2,132,1,146,34,11,56,2,120,65,2,33,10,32,11,67,0,0,128,63,96,69,13,3,12,2,11,32,0,32,0,42,2,120,32,0,42,2,136,1,147,34,11,56,2,120,32,11,32,0,42,2,144,1,34,9,95, + 69,13,2,32,0,32,9,56,2,120,32,0,65,3,54,2,116,12,2,11,32,0,32,0,42,2,120,32,0,42,2,140,1,147,34,11,56,2,120,65,0,33,10,32,11,67,0,0,0,0,95,69,13,1,11,32,0,32,10,54,2,116,11,32,0,32,0,42,2,120,34,11,67, + 0,0,128,63,32,11,67,0,0,128,63,93,27,34,11,67,0,0,0,0,32,11,67,0,0,0,0,94,27,34,11,56,2,120,2,64,2,64,32,0,40,2,200,1,34,10,65,1,72,13,0,32,0,32,10,65,127,106,34,10,54,2,200,1,32,0,42,2,188,1,32,0,42,2, + 196,1,32,10,178,148,147,33,9,12,1,11,32,0,42,2,188,1,33,9,11,32,8,32,11,148,33,8,2,64,2,64,32,0,40,2,184,1,34,10,65,1,72,13,0,32,0,32,10,65,127,106,34,10,54,2,184,1,32,0,42,2,172,1,32,0,42,2,180,1,32,10, + 178,148,147,33,11,12,1,11,32,0,42,2,172,1,33,11,11,67,0,0,0,64,32,8,32,9,67,0,0,64,65,149,146,16,130,128,128,128,0,33,8,2,64,2,64,32,0,40,2,84,34,10,65,1,72,13,0,32,0,32,10,65,127,106,34,10,54,2,84,32, + 0,42,2,72,32,0,42,2,80,32,10,178,148,147,33,9,12,1,11,32,0,42,2,72,33,9,11,2,64,2,64,2,64,2,64,2,64,32,0,40,2,36,65,127,106,14,4,0,1,4,2,4,11,32,0,32,0,42,2,40,32,0,42,2,52,146,34,12,56,2,40,65,2,33,10, + 32,12,67,0,0,128,63,96,69,13,3,12,2,11,32,0,32,0,42,2,40,32,0,42,2,56,147,34,12,56,2,40,32,12,32,0,42,2,64,34,13,95,69,13,2,32,0,32,13,56,2,40,32,0,65,3,54,2,36,12,2,11,32,0,32,0,42,2,40,32,0,42,2,60,147, + 34,12,56,2,40,65,0,33,10,32,12,67,0,0,0,0,95,69,13,1,11,32,0,32,10,54,2,36,11,32,11,32,8,148,33,8,32,0,32,0,42,2,40,34,11,67,0,0,128,63,32,11,67,0,0,128,63,93,27,34,11,67,0,0,0,0,32,11,67,0,0,0,0,94,27, + 34,11,56,2,40,32,9,32,11,148,33,11,2,64,2,64,32,0,40,2,100,34,10,65,1,72,13,0,32,0,32,10,65,127,106,34,10,54,2,100,32,0,42,2,88,32,0,42,2,96,32,10,178,148,147,33,9,12,1,11,32,0,42,2,88,33,9,11,32,8,67, + 0,0,0,0,146,33,8,32,0,65,1,54,2,212,1,32,11,32,9,148,67,0,0,0,0,146,33,9,11,67,0,0,0,0,33,11,67,0,0,0,0,33,12,67,0,0,0,0,33,13,2,64,32,0,40,2,240,1,65,127,70,13,0,32,8,67,0,0,0,0,146,33,8,2,64,2,64,32, + 9,67,0,0,0,0,146,34,9,67,205,204,76,61,93,69,13,0,32,9,67,205,204,76,61,149,67,0,0,150,69,148,33,9,12,1,11,32,0,32,0,41,3,216,1,66,237,156,153,142,4,126,66,185,224,0,124,34,14,66,255,255,255,255,7,131, + 55,3,216,1,32,0,67,0,0,128,63,32,0,42,2,232,1,34,12,147,32,14,167,65,255,255,255,255,7,113,178,67,0,0,128,48,148,67,0,0,128,191,146,148,34,13,32,13,32,12,32,0,42,2,228,1,148,146,146,34,12,56,2,228,1,32, + 9,67,205,204,76,189,146,67,51,51,115,63,149,67,0,0,72,69,148,67,0,0,150,69,146,67,51,51,115,63,148,32,0,42,2,224,1,32,12,148,67,0,0,128,63,146,148,33,9,11,32,0,65,1,54,2,240,1,32,9,67,0,0,0,0,146,33,12, + 32,8,67,0,0,128,61,148,67,231,233,29,66,148,33,13,11,2,64,32,0,40,2,160,3,65,127,70,13,0,32,0,32,12,56,2,132,3,32,0,41,2,228,2,33,14,32,0,32,0,42,2,224,2,34,11,56,2,232,2,32,0,32,0,42,2,220,2,34,15,56, + 2,228,2,32,0,32,14,55,2,236,2,32,0,67,0,0,192,63,32,13,149,34,8,56,2,128,2,32,0,32,8,32,0,42,2,156,2,34,13,148,145,34,9,32,0,42,2,132,2,149,56,2,144,2,32,0,32,9,32,0,42,2,136,2,149,56,2,148,2,32,0,32,7, + 32,6,32,0,45,0,208,2,27,42,2,0,34,9,56,2,140,2,32,0,32,15,32,0,42,2,248,1,34,12,32,12,32,8,32,8,146,148,148,32,9,32,12,148,147,32,13,147,148,34,12,56,2,220,2,32,0,32,11,32,0,42,2,248,1,34,8,32,8,32,0,42, + 2,128,2,34,13,32,13,146,148,148,32,8,32,9,148,147,32,0,42,2,156,2,147,148,34,8,56,2,224,2,32,0,32,12,32,11,32,0,42,2,168,2,34,9,32,9,146,32,0,42,2,132,3,32,0,42,2,136,3,147,148,148,147,34,11,56,2,220,2, + 32,0,32,8,32,0,42,2,228,2,32,0,42,2,168,2,34,9,32,9,146,32,0,42,2,132,3,32,0,42,2,136,3,147,148,148,146,34,9,56,2,224,2,32,0,32,11,32,0,42,2,236,2,32,0,42,2,248,1,34,8,32,0,42,2,140,2,148,32,8,32,8,32, + 0,42,2,128,2,148,148,147,148,146,34,11,56,2,220,2,32,0,32,9,32,0,42,2,240,2,32,0,42,2,248,1,34,8,32,0,42,2,140,2,148,32,8,32,8,32,0,42,2,128,2,148,148,147,148,146,34,8,56,2,224,2,32,0,32,11,32,0,42,2,156, + 2,32,0,42,2,176,2,148,146,34,11,56,2,220,2,32,0,32,8,32,0,42,2,156,2,32,0,42,2,180,2,148,146,34,8,56,2,224,2,32,0,32,11,32,0,42,2,188,2,32,0,42,2,168,2,34,9,32,9,146,32,0,42,2,132,3,32,0,42,2,136,3,147, + 148,148,146,56,2,220,2,32,0,32,8,32,0,42,2,184,2,32,0,42,2,168,2,34,11,32,11,146,32,0,42,2,132,3,32,0,42,2,136,3,147,148,148,147,34,8,56,2,224,2,32,0,32,8,32,0,42,2,168,2,34,11,32,11,146,32,0,42,2,172, + 2,148,32,0,42,2,140,3,148,146,34,8,56,2,224,2,2,64,32,0,45,0,208,2,69,13,0,32,0,42,2,164,2,33,8,32,0,42,2,228,2,32,0,42,2,176,2,147,67,0,0,64,64,16,130,128,128,128,0,33,11,32,0,32,0,42,2,220,2,32,11,32, + 8,67,0,0,0,63,148,148,147,56,2,220,2,32,0,42,2,164,2,33,8,32,0,42,2,232,2,32,0,42,2,180,2,147,67,0,0,64,64,16,130,128,128,128,0,33,11,32,0,32,0,42,2,224,2,32,11,32,8,67,0,0,0,63,148,148,147,34,8,56,2,224, + 2,11,2,64,32,0,42,2,232,2,34,11,32,0,42,2,196,2,34,9,93,69,13,0,32,0,32,8,32,11,32,9,147,34,11,32,11,32,11,148,148,32,0,42,2,160,2,67,0,0,0,191,148,148,146,56,2,224,2,11,32,0,32,0,42,2,220,2,32,0,42,2, + 252,1,34,8,32,8,148,32,0,42,2,128,2,149,34,8,148,56,2,220,2,32,0,32,0,42,2,224,2,32,8,148,34,8,56,2,224,2,2,64,32,8,32,0,42,2,192,2,34,11,94,69,13,0,32,0,32,11,56,2,224,2,32,11,33,8,11,2,64,2,64,32,0,42, + 2,220,2,34,9,32,0,42,2,204,2,34,11,94,13,0,32,9,32,0,42,2,200,2,34,11,93,69,13,1,11,32,0,32,11,56,2,220,2,32,0,42,2,224,2,33,8,11,2,64,2,64,32,8,67,0,0,0,0,94,69,13,0,32,0,65,1,58,0,208,2,32,0,42,2,168, + 2,34,8,32,8,146,32,0,42,2,224,2,148,33,8,12,1,11,32,0,65,0,58,0,208,2,67,0,0,0,0,33,8,11,32,0,32,8,56,2,212,2,32,0,32,0,42,2,168,2,32,0,42,2,248,1,34,8,32,0,42,2,220,2,34,11,32,0,42,2,184,2,147,32,0,42, + 2,224,2,34,9,32,0,42,2,232,2,147,148,148,32,8,32,9,32,0,42,2,188,2,147,32,11,32,0,42,2,228,2,147,148,148,147,148,56,2,156,3,2,64,2,64,2,64,2,64,2,64,32,0,45,0,208,2,69,13,0,32,0,32,0,42,2,144,3,34,12,56, + 2,148,3,32,0,42,2,248,2,34,11,32,0,42,2,172,2,34,9,148,34,13,32,0,42,2,248,1,34,15,148,32,0,42,2,212,2,34,8,149,32,9,32,0,42,2,168,2,34,16,32,16,32,0,42,2,252,2,67,0,0,64,65,148,148,148,148,32,8,32,8,32, + 8,148,34,9,148,149,146,34,16,187,34,17,32,17,162,32,11,67,0,0,0,63,148,32,9,149,32,11,67,0,0,128,63,32,8,32,0,42,2,216,2,34,9,148,149,67,0,0,128,63,32,9,32,9,148,149,147,148,147,34,11,187,68,0,0,0,0,0, + 0,16,192,162,32,0,42,2,136,3,32,0,42,2,132,3,147,32,15,32,12,32,13,148,148,32,8,149,147,187,162,160,34,17,189,34,14,167,33,10,2,64,32,14,66,32,136,167,34,18,65,128,128,192,255,3,71,34,19,13,0,68,0,0,0, + 0,0,0,240,63,33,20,32,10,69,13,4,11,2,64,2,64,32,18,65,255,255,255,255,7,113,34,21,65,128,128,192,255,7,75,13,0,32,21,65,128,128,192,255,7,70,32,10,65,0,71,113,69,13,1,11,32,17,68,0,0,0,0,0,0,224,63,160, + 33,20,12,4,11,32,14,66,127,85,34,22,13,1,32,10,13,2,2,64,2,64,32,18,65,127,74,13,0,32,18,65,128,128,128,128,120,70,13,1,32,18,65,128,128,192,255,123,70,13,1,32,18,65,128,128,64,70,13,1,12,4,11,32,18,69, + 13,0,32,18,65,128,128,192,255,7,70,13,0,32,19,13,3,11,32,17,153,33,20,32,22,13,3,32,21,65,128,128,192,255,3,71,13,3,32,20,32,20,161,34,20,32,20,163,33,20,12,3,11,32,0,65,0,54,2,144,3,67,0,0,0,0,33,8,12, + 3,11,32,17,159,33,20,12,1,11,32,17,32,17,161,34,20,32,20,163,33,20,11,32,0,67,0,0,0,0,32,20,182,32,17,68,0,0,0,0,0,0,0,0,99,27,32,16,147,32,11,32,11,146,149,34,8,56,2,144,3,32,0,42,2,132,3,32,8,32,8,32, + 0,42,2,248,2,34,9,67,0,0,0,191,148,148,148,32,0,42,2,212,2,34,11,32,11,148,34,12,149,146,32,0,42,2,248,1,32,9,32,0,42,2,172,2,34,13,148,32,8,32,0,42,2,148,3,147,148,148,32,11,149,147,32,8,32,13,32,0,42, + 2,168,2,34,9,32,9,32,0,42,2,252,2,67,0,0,64,193,148,148,148,148,148,32,11,32,12,148,149,146,33,8,11,32,0,32,8,56,2,140,3,32,0,65,1,54,2,160,3,32,0,32,0,42,2,244,2,32,0,42,2,156,3,32,0,42,2,144,3,146,34, + 8,148,56,2,136,3,32,8,67,0,0,0,0,146,67,0,64,28,69,148,33,11,11,67,0,0,0,0,33,9,67,0,0,0,0,33,8,2,64,32,0,40,2,172,3,65,127,70,13,0,32,0,65,1,54,2,172,3,32,0,42,2,164,3,33,8,32,0,32,11,56,2,164,3,32,0, + 32,11,67,82,184,126,63,148,32,0,42,2,168,3,67,164,112,125,63,148,146,32,8,67,82,184,126,63,148,147,34,8,56,2,168,3,32,8,67,0,0,0,0,146,33,8,11,2,64,32,0,40,2,136,139,1,65,127,70,13,0,2,64,32,0,40,2,176, + 3,69,13,0,32,8,33,11,2,64,32,0,40,2,204,138,1,34,10,65,1,72,13,0,32,8,32,0,42,2,220,138,1,32,0,32,0,40,2,232,138,1,65,192,7,106,34,19,32,0,40,2,208,138,1,107,65,192,7,111,34,18,65,192,7,106,32,18,32,18, + 65,0,72,27,65,2,116,106,65,204,236,0,106,42,2,0,148,146,33,11,32,10,65,0,32,10,65,0,74,27,34,10,65,1,70,13,0,32,11,32,0,42,2,224,138,1,32,0,32,19,32,0,40,2,212,138,1,107,65,192,7,111,34,18,65,192,7,106, + 32,18,32,18,65,0,72,27,65,2,116,106,65,204,236,0,106,42,2,0,148,146,33,11,32,10,65,2,70,13,0,32,11,32,0,42,2,228,138,1,32,0,32,19,32,0,40,2,216,138,1,107,65,192,7,111,34,10,65,192,7,106,32,10,32,10,65, + 0,72,27,65,2,116,106,65,204,236,0,106,42,2,0,148,146,33,11,11,32,0,32,0,40,2,232,138,1,65,2,116,106,65,204,236,0,106,32,8,56,2,0,32,0,32,0,40,2,232,138,1,65,1,106,65,192,7,111,34,10,65,192,7,106,32,10, + 32,10,65,0,72,27,54,2,232,138,1,32,3,32,0,40,2,192,33,65,2,116,106,34,10,32,11,32,5,32,0,40,2,200,108,65,2,116,106,42,2,0,32,0,42,2,128,139,1,148,146,34,8,32,0,42,2,252,138,1,34,11,32,4,32,0,40,2,184,18, + 65,2,116,106,42,2,0,32,10,42,2,0,34,9,32,0,42,2,248,138,1,148,147,34,12,32,8,32,11,148,147,34,8,148,146,56,2,0,32,0,32,0,40,2,192,33,65,1,106,65,224,3,111,34,10,65,224,3,106,32,10,32,10,65,0,72,27,34,10, + 54,2,192,33,2,64,32,10,32,0,40,2,188,33,34,18,72,13,0,32,0,32,10,32,18,107,65,224,3,111,34,10,65,224,3,106,32,10,32,10,65,0,72,27,54,2,192,33,11,32,4,32,0,40,2,184,18,65,2,116,106,32,9,32,12,32,0,42,2, + 248,138,1,148,146,56,2,0,32,0,32,0,40,2,184,18,65,1,106,65,224,3,111,34,10,65,224,3,106,32,10,32,10,65,0,72,27,34,10,54,2,184,18,2,64,32,10,32,0,40,2,180,18,34,18,72,13,0,32,0,32,10,32,18,107,65,224,3, + 111,34,10,65,224,3,106,32,10,32,10,65,0,72,27,54,2,184,18,11,32,0,32,8,32,0,42,2,244,138,1,148,32,0,42,2,240,138,1,32,0,42,2,236,138,1,148,147,34,11,56,2,236,138,1,32,5,32,0,40,2,200,108,65,2,116,106,32, + 11,56,2,0,32,0,32,0,40,2,200,108,65,1,106,65,224,18,111,34,10,65,224,18,106,32,10,32,10,65,0,72,27,34,10,54,2,200,108,2,64,32,10,32,0,40,2,196,108,34,18,72,13,0,32,0,32,10,32,18,107,65,224,18,111,34,10, + 65,224,18,106,32,10,32,10,65,0,72,27,54,2,200,108,11,32,8,32,0,42,2,132,139,1,148,33,8,11,32,0,65,1,54,2,136,139,1,32,8,67,0,0,0,0,146,33,9,11,32,1,32,0,40,2,0,65,2,116,106,32,9,67,0,0,0,0,146,56,2,0,32, + 0,32,0,40,2,0,65,1,106,34,10,54,2,0,32,10,32,2,71,13,0,11,11,32,0,65,0,54,2,0,11,11,142,128,128,128,0,1,0,65,0,11,8,0,0,0,0,0,0,0,0,0,217,134,128,128,0,7,108,105,110,107,105,110,103,2,8,175,134,128,128, + 0,25,0,32,1,18,95,115,101,110,100,69,118,101,110,116,95,105,110,95,109,105,100,105,0,2,2,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,3,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,4,19,95,115,101,110,100,69,118,101,110,116,95,105,110, + 95,112,105,110,99,104,0,32,5,19,95,115,101,110,100,69,118,101,110,116,95,105,110,95,99,104,101,101,107,0,32,6,20,95,115,101,110,100,69,118,101,110,116,95,105,110,95,115,116,114,97,105,110,0,32,7,29,95, + 115,101,110,100,69,118,101,110,116,95,105,110,95,115,116,114,97,105,110,73,110,116,101,110,115,105,116,121,1,2,12,46,76,95,102,114,101,113,117,101,110,99,121,0,0,8,0,32,8,35,95,115,101,110,100,69,118,101, + 110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110,118,65,116,116,97,99,107,84,105,109,101,0,32,9,34,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110, + 118,68,101,99,97,121,84,105,109,101,0,32,10,37,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110,118,83,117,115,116,97,105,110,76,101,118,101,108,0,32,11,36,95, + 115,101,110,100,69,118,101,110,116,95,105,110,95,112,114,101,115,115,117,114,101,69,110,118,82,101,108,101,97,115,101,84,105,109,101,0,32,12,28,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105, + 116,99,104,69,110,118,65,109,111,117,110,116,0,32,13,32,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,65,116,116,97,99,107,84,105,109,101,0,32,14,31,95,115,101,110,100, + 69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,68,101,99,97,121,84,105,109,101,0,32,15,34,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,83,117,115,116, + 97,105,110,76,101,118,101,108,0,32,16,33,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,69,110,118,82,101,108,101,97,115,101,84,105,109,101,0,32,17,24,95,115,101,110,100,69,118, + 101,110,116,95,105,110,95,114,101,118,101,114,98,77,111,100,101,0,2,18,40,46,76,65,117,100,111,108,111,110,95,95,82,101,118,101,114,98,95,95,82,101,80,111,111,116,95,95,115,101,116,80,97,114,97,109,101, + 116,101,114,115,0,16,0,0,32,19,28,95,115,101,110,100,69,118,101,110,116,95,105,110,95,112,105,116,99,104,71,108,105,100,101,84,105,109,101,0,32,20,37,95,115,101,110,100,69,118,101,110,116,95,105,110,95, + 112,105,116,99,104,66,101,110,100,82,97,110,103,101,83,101,109,105,116,111,110,101,115,0,32,21,24,95,115,101,110,100,69,118,101,110,116,95,105,110,95,109,111,100,87,104,101,101,108,79,110,0,32,22,10,105, + 110,105,116,105,97,108,105,115,101,0,32,23,12,97,100,118,97,110,99,101,66,108,111,99,107,5,149,128,128,128,0,1,17,46,98,115,115,46,46,76,95,102,114,101,113,117,101,110,99,121,3,0,0,241,128,128,128,0,10, + 114,101,108,111,99,46,67,79,68,69,5,23,0,74,1,0,156,20,2,3,166,23,7,0,0,223,31,18,3,159,32,7,0,0,218,34,19,3,159,35,7,0,0,132,36,19,3,200,36,7,0,0,175,37,19,3,129,38,7,0,0,210,38,19,0,233,38,19,0,248,38, + 19,0,134,39,19,3,155,39,7,0,3,137,42,7,0,3,224,46,7,0,3,237,50,7,0,0,227,51,18,0,232,55,1,0,188,65,1,0,246,65,1,]); + } +} diff --git a/assets/example_patches/CompuFart/cmaj_api/assets/cmajor-logo.svg b/assets/example_patches/CompuFart/cmaj_api/assets/cmajor-logo.svg new file mode 100644 index 00000000..70685d54 --- /dev/null +++ b/assets/example_patches/CompuFart/cmaj_api/assets/cmajor-logo.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/example_patches/CompuFart/cmaj_api/cmaj-event-listener-list.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-event-listener-list.js new file mode 100644 index 00000000..0c13ea9e --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-generic-patch-view.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-generic-patch-view.js new file mode 100644 index 00000000..0370dc96 --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-midi-helpers.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-midi-helpers.js new file mode 100644 index 00000000..1cc4933b --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-parameter-controls.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-parameter-controls.js new file mode 100644 index 00000000..c6290d05 --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-patch-connection.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-patch-connection.js new file mode 100644 index 00000000..2fff73c5 --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-patch-view.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-patch-view.js new file mode 100644 index 00000000..8052a30e --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-piano-keyboard.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-piano-keyboard.js new file mode 100644 index 00000000..23ef7a1b --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj-server-session.js b/assets/example_patches/CompuFart/cmaj_api/cmaj-server-session.js new file mode 100644 index 00000000..813f5fcd --- /dev/null +++ b/assets/example_patches/CompuFart/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/CompuFart/cmaj_api/cmaj_audio_worklet_helper.js b/assets/example_patches/CompuFart/cmaj_api/cmaj_audio_worklet_helper.js new file mode 100644 index 00000000..aae7dd7a --- /dev/null +++ b/assets/example_patches/CompuFart/cmaj_api/cmaj_audio_worklet_helper.js @@ -0,0 +1,719 @@ + +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 () => {}; + + 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; + } + + return (channels, blockSize) => + { + for (var i = 0; i < handlers.length; i++) + handlers[i] (channels, blockSize, targetChannels[i]); + } + } + + 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"); + + var inputChannelCount = 0; + var outputChannelCount = 0; + + audioInputEndpoints.forEach ((endpoint) => { inputChannelCount = inputChannelCount + endpoint.numAudioChannels; }); + audioOutputEndpoints.forEach ((endpoint) => { outputChannelCount = outputChannelCount + endpoint.numAudioChannels; }); + + 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/CompuFart/index.html b/assets/example_patches/CompuFart/index.html new file mode 100644 index 00000000..a66350c4 --- /dev/null +++ b/assets/example_patches/CompuFart/index.html @@ -0,0 +1,202 @@ + + + + Cmajor Patch + + + +
+
+
+
+
+
+ CompuFart + CompuFart Fart Synthesizer + + - Click to Start - +
+
+ +
+
+ + + + + + diff --git a/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js b/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js index 1b45d5cd..e4d09fee 100644 --- a/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js +++ b/assets/example_patches/ElectricPiano/cmaj_Electric_Piano.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'ElectricPiano.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/HelloWorld/cmaj_Hello_World.js b/assets/example_patches/HelloWorld/cmaj_Hello_World.js index aeef4e66..8ec850dd 100644 --- a/assets/example_patches/HelloWorld/cmaj_Hello_World.js +++ b/assets/example_patches/HelloWorld/cmaj_Hello_World.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'HelloWorld.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/Piano/cmaj_Piano.js b/assets/example_patches/Piano/cmaj_Piano.js index 23e132b7..45c5053c 100644 --- a/assets/example_patches/Piano/cmaj_Piano.js +++ b/assets/example_patches/Piano/cmaj_Piano.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'Piano.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/PirkleFilters/cmaj_vafilters.js b/assets/example_patches/PirkleFilters/cmaj_vafilters.js index 905e971f..0ba4bc5c 100644 --- a/assets/example_patches/PirkleFilters/cmaj_vafilters.js +++ b/assets/example_patches/PirkleFilters/cmaj_vafilters.js @@ -3,7 +3,7 @@ // 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.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/Pro54/cmaj_Pro54.js b/assets/example_patches/Pro54/cmaj_Pro54.js index c74b4605..b9807543 100644 --- a/assets/example_patches/Pro54/cmaj_Pro54.js +++ b/assets/example_patches/Pro54/cmaj_Pro54.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'Pro54.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/Replicant/cmaj_Replicant.js b/assets/example_patches/Replicant/cmaj_Replicant.js index 8d2dbddc..fd2068b5 100644 --- a/assets/example_patches/Replicant/cmaj_Replicant.js +++ b/assets/example_patches/Replicant/cmaj_Replicant.js @@ -3,7 +3,7 @@ // 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.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js b/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js index f39f42da..70e92b83 100644 --- a/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js +++ b/assets/example_patches/RingMod/cmaj_Ring_Mod_Demo.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'RingMod.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/STunedBar6/cmaj_STunedBar6.js b/assets/example_patches/STunedBar6/cmaj_STunedBar6.js index 79eb0c6d..33762f4a 100644 --- a/assets/example_patches/STunedBar6/cmaj_STunedBar6.js +++ b/assets/example_patches/STunedBar6/cmaj_STunedBar6.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'STunedBar6.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/Tremolo/cmaj_Tremolo.js b/assets/example_patches/Tremolo/cmaj_Tremolo.js index 97b28bab..0044b923 100644 --- a/assets/example_patches/Tremolo/cmaj_Tremolo.js +++ b/assets/example_patches/Tremolo/cmaj_Tremolo.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'Tremolo.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/assets/example_patches/Tremolo/ui/stompbox/view.js b/assets/example_patches/Tremolo/ui/stompbox/view.js index f2e50966..469dea21 100644 --- a/assets/example_patches/Tremolo/ui/stompbox/view.js +++ b/assets/example_patches/Tremolo/ui/stompbox/view.js @@ -341,10 +341,10 @@ function makeRotatable ({ { const degrees = toRotation (nextValue); - if (! force && state.rotation === degrees) return; + if (! force && state.rotation === degrees) + return; state.rotation = degrees; - element.style.transform = `rotate(${degrees}deg)` }; @@ -353,19 +353,17 @@ function makeRotatable ({ const force = true; update (initialValue, force); - let accumulatedRotation = undefined; + let accumulatedRotation, touchIdentifier, previousClientY; + + const nextRotation = (rotation, delta) => + { + const clamp = (v, min, max) => Math.min (Math.max (v, min), max); + return clamp (rotation - delta, -maxRotation, maxRotation); + }; 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, -maxRotation, maxRotation); - }; - const speedMultiplier = event.shiftKey ? 0.25 : 1.5; accumulatedRotation = nextRotation (accumulatedRotation, event.movementY * speedMultiplier); onEdit?.(toValue (accumulatedRotation)); @@ -387,10 +385,48 @@ function makeRotatable ({ window.addEventListener ("mouseup", onMouseUp); }; + const onTouchMove = (event) => + { + for (const touch of event.changedTouches) + { + if (touch.identifier == touchIdentifier) + { + event.preventDefault(); // avoid scrolling whilst dragging + const speedMultiplier = event.shiftKey ? 0.25 : 1.5; + const movementY = touch.clientY - previousClientY; + previousClientY = touch.clientY; + accumulatedRotation = nextRotation (accumulatedRotation, movementY * speedMultiplier); + onEdit?.(toValue (accumulatedRotation)); + } + } + }; + + const onTouchStart = (event) => + { + accumulatedRotation = state.rotation; + previousClientY = event.changedTouches[0].clientY + touchIdentifier = event.changedTouches[0].identifier; + onBeginEdit?.(); + window.addEventListener ("touchmove", onTouchMove); + window.addEventListener ("touchend", onTouchEnd); + event.preventDefault(); + }; + + const onTouchEnd = (event) => + { + previousClientY = undefined; + accumulatedRotation = undefined; + window.removeEventListener ("touchmove", onTouchMove); + window.removeEventListener ("touchend", onTouchEnd); + onEndEdit?.(); + event.preventDefault(); + }; + const onReset = () => setValueAsGesture (initialValue, { onBeginEdit, onEdit, onEndEdit }); element.addEventListener ("mousedown", onMouseDown); element.addEventListener ("dblclick", onReset); + element.addEventListener ('touchstart', onTouchStart); return update; } diff --git a/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js b/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js index bd03f210..d6d8b1b2 100644 --- a/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js +++ b/assets/example_patches/ZitaReverb/cmaj_Zita_Reverb.js @@ -3,7 +3,7 @@ // This file contains a Javascript/Webassembly/WebAudio export of the Cmajor // patch 'ZitaReverb.cmajorpatch'. // -// This file was auto-generated by the Cmajor toolkit v1.0.2380 +// This file was auto-generated by the Cmajor toolkit v1.0.2381 // // To use it, import this module into your HTML/Javascript code and call // `createAudioWorkletNodePatchConnection()`. The AudioWorkletPatchConnection diff --git a/docs/Examples/CompuFart.md b/docs/Examples/CompuFart.md new file mode 100644 index 00000000..7e8fa1f8 --- /dev/null +++ b/docs/Examples/CompuFart.md @@ -0,0 +1,37 @@ +--- +layout: default +title: CompuFart +parent: Examples +nav_order: 11 +has_children: false +has_toc: false +--- + +## CompuFart + +Thanks to [Alex Fink](https://github.com/alexmfink) for this example. + +CompuFart is a fart sound synthesizer that generates sound by physically modeling wind passing through an asshole. This version is programmed in Cmajor. + +### How to Use + +You can use the provided fart synthesizer in your DAW or with a handful of tools provide by Cmajor Software. Alternatively, you can use the physical model in your own Cmajor patch! + +### Building your own Fart Engine + +The quickest way to use the physical model is to use the `processor` named `Terrance` (the phsyical model) along with the input and output interface `processor`s, `TerranceInputInterface` and `TerranceOutputInterface`. The interface `processor`s provide a quick way to get meaningful and useful parameters and audio into and out of the model. The model should oscillate when sufficient pressure is provided to the artificial sphincter. + +The provided mono synth, `CompuFartSynth` shows one possible way to construct a digital instrument with the fart engine. + +### Notes + +* The model is not currently (pitch) tuned. However, using typical pitch control inputs (keyboard, bend) should provide relative pitch control. +* There is currently no guarantee of compatibility or consistency between different versions of the model, synth, or other patches and code. If you wish to preserve a particular sound, it is recommended that you record it and make note of the parameter values and the version and git commit SHA. Of course, you can also fork the code. + + +Click here to view the source code. + + +