diff --git a/development/docs-template.ejs b/development/docs-template.ejs index d704b5d46f..9f498e2f24 100644 --- a/development/docs-template.ejs +++ b/development/docs-template.ejs @@ -141,6 +141,44 @@ margin: 1rem 0; font-size: small; } + + /* General Alert Styles */ + .alert { + padding-left: 15px; + line-height: 1; + } + + /* Border colors for specific alerts */ + .alert.alert-note { border-left: .25em solid #1f6feb; } + .alert.alert-tip { border-left: .25em solid #238636; } + .alert.alert-important { border-left: .25em solid #8957e5; } + .alert.alert-warning { border-left: .25em solid #9e6a03; } + .alert.alert-caution { border-left: .25em solid #da3633; } + + /* Shared Styling for Alert Text */ + .alert p:first-child { + padding-top: 10px; + font-weight: 500; + font-size: 14px; + display: flex; + } + + .alert p:last-child { + padding-bottom: 10px; + } + + /* Specific Color Overrides for Each Alert */ + .alert.alert-note > p:first-child { color: #4493f8; } + .alert.alert-tip > p:first-child { color: #3fb950; } + .alert.alert-important > p:first-child { color: #ab7df8; } + .alert.alert-warning > p:first-child { color: #d29922; } + .alert.alert-caution > p:first-child { color: #f85149; } + + /* Icon Spacing */ + div.alert svg { + margin-right: 8px; + } + diff --git a/development/render-docs.js b/development/render-docs.js index 3bdcfab88e..378cc38d63 100644 --- a/development/render-docs.js +++ b/development/render-docs.js @@ -2,6 +2,15 @@ const path = require("path"); const MarkdownIt = require("markdown-it"); const renderTemplate = require("./render-template"); +// From GitHub's markdown alert SVG icons: https://github.com/primer/octicons +const blockIcons = { + note: ``, + tip: ``, + important: ``, + warning: ``, + caution: ``, +}; + const md = new MarkdownIt({ html: true, linkify: true, @@ -25,6 +34,74 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) { )}">${md.utils.escapeHtml(token.content)}`; }; +md.block.ruler.before( + "blockquote", + "custom_blockquote", + function (state, startLine, endLine, silent) { + const marker = state.src.slice( + state.bMarks[startLine], + state.eMarks[startLine] + ); + + const match = marker.match(/^\s*>\s*\[!([A-Z]+)]\s*(.*)/); + if (!match) return false; + + const type = match[1].toLowerCase(); + if (!blockIcons[type]) { + throw new TypeError(`Invalid alert type: ${match[1]}`); + } + const icon = blockIcons[type]; + + if (silent) return true; + + // Open alert div + const tokenOpen = state.push("html_block", "", 0); + tokenOpen.content = `
`; + + // Render title with Markdown support + state.push("paragraph_open", "p", 1); + const tokenTitle = state.push("inline", "", 0); + tokenTitle.content = `${icon} **${type.charAt(0).toUpperCase() + type.slice(1)}**`; + tokenTitle.children = []; + state.push("paragraph_close", "p", -1); + + // Process all lines inside the blockquote + let nextLine = startLine + 1; + while (nextLine < endLine) { + const nextMarker = state.src.slice( + state.bMarks[nextLine], + state.eMarks[nextLine] + ); + + if (/^\s*>/.test(nextMarker)) { + const lineContent = nextMarker.replace(/^\s*>?\s*/, "").trim(); + + if (lineContent === "") { + state.push("paragraph_open", "p", 1); + state.push("paragraph_close", "p", -1); + } else { + state.push("paragraph_open", "p", 1); + const token = state.push("inline", "", 0); + token.content = lineContent; + token.children = []; + state.push("paragraph_close", "p", -1); + } + + nextLine++; + } else { + break; + } + } + + // Close alert div + const tokenClose = state.push("html_block", "", 0); + tokenClose.content = `
`; + + state.line = nextLine; + return true; + } +); + /** * @param {string} markdownSource Markdown code * @param {string} slug Path slug like 'TestMuffin/fetch' diff --git a/docs/CST1229/zip.md b/docs/CST1229/zip.md index 5e0fa44ea6..163db0a625 100644 --- a/docs/CST1229/zip.md +++ b/docs/CST1229/zip.md @@ -45,7 +45,8 @@ The type can be one of the following: The name is used for dealing with multiple archives at time; it can be any non-empty string and does *not* have to be the archive's filename. -If the file is not of zip format (like RAR or 7z) or is password-protected, it won't be opened. Make sure to check if it loaded successfully with the `error opening archive?` block. +> [!TIP] +> If the file is not of zip format (like RAR or 7z) or is password-protected, it won't be opened. Make sure to check if it loaded successfully with the `error opening archive?` block. --- diff --git a/docs/CubesterYT/WindowControls.md b/docs/CubesterYT/WindowControls.md index 2d6e61a07b..b3b396e070 100644 --- a/docs/CubesterYT/WindowControls.md +++ b/docs/CubesterYT/WindowControls.md @@ -2,11 +2,12 @@ This extension provides a set of blocks that gives you greater control over the Program Window. -**Note: Most of these blocks only work in Electron, Pop Ups/Web Apps containing HTML packaged projects, and normal Web Apps.** - -Examples include, but are not limited to: **TurboWarp Desktop App, TurboWarp Web App, Pop Up/Web App windows that contain the HTML packaged project, and plain Electron.** - -**Blocks that still work outside of these will be specified.** +> [!NOTE] +> **Most of these blocks only work in Electron, Pop Ups/Web Apps containing HTML packaged projects, and normal Web Apps.** +> +> Examples include, but are not limited to: **TurboWarp Desktop App, TurboWarp Web App, Pop Up/Web App windows that contain the HTML packaged project, and plain Electron.** +> +> **Blocks that still work outside of these will be specified.** ## Move Window Block diff --git a/docs/DNin/wake-lock.md b/docs/DNin/wake-lock.md index dc1e59f3cf..3f2d9b5e3d 100644 --- a/docs/DNin/wake-lock.md +++ b/docs/DNin/wake-lock.md @@ -19,7 +19,8 @@ Wake lock will also be released automatically when the project stops or is resta ## Browser support -Not all browsers support wake lock (notably, Firefox does not). In these browsers requesting wake lock will not do anything. +> [!WARNING] +> Not all browsers support wake lock **(notably, Firefox does not)**. In these browsers requesting wake lock will not do anything. ## Note diff --git a/docs/Lily/Skins.md b/docs/Lily/Skins.md index 64522d77fa..79bb09b7f7 100644 --- a/docs/Lily/Skins.md +++ b/docs/Lily/Skins.md @@ -26,9 +26,10 @@ load skin from (costume 1 v) as [my skin] :: #6b56ff ``` The second way is by loading a skin from a costume. -It's important to note that this block will require the Advanced Option "Remove raw asset data after loading to save RAM" to be disabled in the packager in order for this block to work correctly in a packaged environment. **You do not need to do this within the editor.** - -If you intend to package your project, we don't encourage using this block for that reason. **None of the other blocks in this extension require this option to be disabled.** +> [!IMPORTANT] +> It's important to note that this block will require the Advanced Option "Remove raw asset data after loading to save RAM" to be disabled in the packager in order for this block to work correctly in a packaged environment. **You do not need to do this within the editor.** +> +> If you intend to package your project, we don't encourage using this block for that reason. **None of the other blocks in this extension require this option to be disabled.** --- diff --git a/docs/TheShovel/ShovelUtils.md b/docs/TheShovel/ShovelUtils.md index 06b659a5fe..50abae281e 100644 --- a/docs/TheShovel/ShovelUtils.md +++ b/docs/TheShovel/ShovelUtils.md @@ -2,7 +2,8 @@ Shovel Utils is an extension focused mostly on injecting and modifying sprites and assets inside the project, as well as several other functions. -**Disclaimer: Modifying and importing assets can be dangerous, and has the potential to corrupt your project. Be careful!** +> [!CAUTION] +> **Modifying and importing assets can be dangerous, and has the potential to corrupt your project. Be careful!** ## Importing Assets @@ -10,7 +11,8 @@ Shovel Utils offers an easy way to import several types of assets, including spr --- -**This goes for all blocks that fetch from a link: If you're experiences errors and are not able to import an asset from a link, check your console! You may be running into a CORS error. To resolve this, use a proxy like [corsproxy.io](https://corsproxy.io).** +> [!TIP] +> **This goes for all blocks that fetch from a link: If you're experiences errors and are not able to import an asset from a link, check your console! You may be running into a CORS error. To resolve this, use a proxy like [corsproxy.io](https://corsproxy.io).** ```scratch import sprite from [Link or data uri here] diff --git a/docs/Xeltalliv/simple3D.md b/docs/Xeltalliv/simple3D.md index 8fb8612456..e4376ca4aa 100644 --- a/docs/Xeltalliv/simple3D.md +++ b/docs/Xeltalliv/simple3D.md @@ -42,7 +42,9 @@ So in short, **this extension does not have any kind of scenes, objects, cameras 3D models consist of vertices which together form primitives (points, lines, triangles). Each vertex has either 2D (XY) or 3D (XYZ) location described with 2 or 3 numbers respectively. Before drawing the mesh, you would usually set up transformation, which tells how to take those initial locations and transform them to correct location on your 2D screen. The typical way to do it, is to chain multiple simple transformations together. Simple transformations can be translation (offsetting), rotation, scaling, mirroring, skewing, etc. ## Drawing things -**Note:** For a more complete tutorial, see [here](https://xeltalliv.github.io/simple3d-extension/examples/) (external link). + +> [!TIP] +> For a more complete tutorial, see [here](https://xeltalliv.github.io/simple3d-extension/examples/) (external link). For now let's not worry about transformations and just draw something as is. First step would be to clear the screen: @@ -543,7 +545,8 @@ set [my mesh] from [off v] [list v] :: sensing ``` Decodes a 3D model file and uploads it into a mesh. Block continues instantly, but the model loading is performed in a separate thread, and it finishes with a delay. Currently, only one thread is used, so everything is queued and processed one by one. In the future, multiple threads might be used. -**Note: This block is designed as a more of a shortcut for quick testing, rather than the main way of loading 3D models. For anything more complex make your own 3D model parser.** +> [!IMPORTANT] +> This block is designed as a more of a shortcut for quick testing, rather than the main way of loading 3D models. For anything more complex, make your own 3D model parser. File formats: - [obj](https://en.wikipedia.org/wiki/Wavefront_.obj_file) is a very common and well known 3D model file format. It supports UV texture coordinates, materials with colors and textures. However it does not have a standartized way to do vertex colors. This block implements a non-standart but widely supported way to represent vertex colors as 4th - 7th elements of `v`. The OBJ and MTL specification describes a lot of features, only some of which are currently (or even can be) supported by this importer. In particular, there is currently no way to import models which use multiple textures as this extensions only supports 1 texture per mesh. Normals and anything lighting related isn't and can't be supported. **In case both OBJ and MTL files need to be imported, combine them all into 1 list sequentially, first all of the MTL files and then the OBJ file.** @@ -714,8 +717,11 @@ This block may cause stutter when drawing something for the first time, as it wi ``` Creates texture from image at specified URL. Will show a prompt if URL is not approved. -If an image fails to load, you can usually open browser console and see what the error is. (F12 or Ctrl+Shift+I) -**Note that websites cannot access any data from any other websites unless those other sites explicetly allow it. The correct term for it is CORS (Cross Origin Resource Sharing). You can use some CORS proxy to bypass it.** +If an image fails to load, you can usually open browser console and see what the error is (F12 or Ctrl+Shift+I). + +> [!WARNING] +> Websites cannot access any data from any other websites unless those other sites explicetly allow it. The correct term for it is CORS (Cross Origin Resource Sharing). You can use some CORS proxy to bypass it. + 🐢 Texture gets loaded with a delay. --- diff --git a/docs/ar.md b/docs/ar.md index 5224b856c6..be7193e130 100644 --- a/docs/ar.md +++ b/docs/ar.md @@ -6,7 +6,8 @@ - [ARCore](https://play.google.com/store/apps/details?id=com.google.ar.core) (if on Android) - browser with [WebXR API and immersive-ar](https://immersive-web.github.io/webxr-samples/report/) session type support -At the moment of writing, only Chromium-based browsers on Android support immersive-ar session type. +> [!WARNING] +> At the moment of writing, only Chromium-based browsers on Android support immersive-ar session type. ## Other information diff --git a/docs/box2d.md b/docs/box2d.md index 8fe43e9cf3..9c78dfee2d 100644 --- a/docs/box2d.md +++ b/docs/box2d.md @@ -53,7 +53,10 @@ Make physics apply to this sprite. It can also collide with other sprites that h - `this circle`: Enable physics for the current sprite or clone as if it were shaped like a circle. - `all sprites`: Enable physics for all sprites. -Precision mode will make the sprite work extra hard to make sure it doesn't overlap with anything. Note that this can decrease performance and even cause the project to get stuck, so use with care. +Precision mode will make the sprite work extra hard to make sure it doesn't overlap with anything. + +> [!CAUTION] +> Precision mode should be used with care as it can decrease performance and even cause the project to get stuck. --- diff --git a/docs/godslayerakp/ws.md b/docs/godslayerakp/ws.md index eb9600b70b..b6f5301b73 100644 --- a/docs/godslayerakp/ws.md +++ b/docs/godslayerakp/ws.md @@ -15,7 +15,8 @@ The URL should start with `ws://` or `wss://`. For security reasons, `ws://` URL Something simple to play with is the echo server: `wss://echoserver.redman13.repl.co`. Any message you send to it, it'll send right back to you. -Note that connections are **per sprite**. Each sprite (or clone) can connect to one server at a time. Multiple sprites can connect to the same or different servers as much as your computer allows, but note those will all be separate connections. +> [!NOTE] +> Connections are **per sprite**. Each sprite (or clone) can connect to one server at a time. Multiple sprites can connect to the same or different servers as much as your computer allows, but note those will all be separate connections. --- diff --git a/docs/steamworks.md b/docs/steamworks.md index 39006b0351..4b7bd062f6 100644 --- a/docs/steamworks.md +++ b/docs/steamworks.md @@ -27,9 +27,10 @@ You can run the packaged executable directly as usual; you don't need to start t ## Security considerations -Using the Steamworks extension will not prevent people from pirating your game. - -The Steamworks extension is also inherently client-side, so a cheater could manipulate all of the Steamworks blocks to return whatever they want. You shouldn't use them for things that are security critical. +> [!CAUTION] +> **Using the Steamworks extension will not prevent people from pirating your game.** +> +> The Steamworks extension is also inherently client-side, so a cheater could manipulate all of the Steamworks blocks to return whatever they want. You shouldn't use them for things that are security critical. ## Demo game diff --git a/extensions/Lily/AllMenus.js b/extensions/Lily/AllMenus.js index d51f05ba0c..6ea7e20a47 100644 --- a/extensions/Lily/AllMenus.js +++ b/extensions/Lily/AllMenus.js @@ -3,6 +3,7 @@ // Description: Special category with every menu from every Scratch category and extensions. // By: LilyMakesThings // License: MIT AND LGPL-3.0 +// Scratch-compatible: true (function (Scratch) { "use strict"; diff --git a/extensions/SharkPool/Camera.js b/extensions/SharkPool/Camera.js index 8fe9dc6a93..a57d607826 100644 --- a/extensions/SharkPool/Camera.js +++ b/extensions/SharkPool/Camera.js @@ -4,7 +4,7 @@ // By: SharkPool // License: MIT -// Version V.1.0.07 +// Version V.1.0.08 (function (Scratch) { "use strict"; @@ -53,6 +53,10 @@ } // camera utils + const radianConstant = Math.PI / 180; + const epsilon = 1e-12; + const applyEpsilon = (value) => (Math.abs(value) < epsilon ? 0 : value); + function setupState(drawable) { drawable[cameraSymbol] = { name: "default", @@ -65,23 +69,26 @@ function translatePosition(xy, invert, camData) { if (invert) { - const invRads = (camData.ogDir / 180) * Math.PI; + const invRads = camData.ogDir * radianConstant; const invSin = Math.sin(invRads), invCos = Math.cos(invRads); const scaledX = xy[0] / camData.ogSZ; const scaledY = xy[1] / camData.ogSZ; const invOffX = scaledX * invCos + scaledY * invSin; const invOffY = -scaledX * invSin + scaledY * invCos; - return [invOffX - camData.ogXY[0], invOffY - camData.ogXY[1]]; + return [ + applyEpsilon(invOffX - camData.ogXY[0]), + applyEpsilon(invOffY - camData.ogXY[1]), + ]; } else { - const rads = (camData.dir / 180) * Math.PI; + const rads = camData.dir * radianConstant; const sin = Math.sin(rads), cos = Math.cos(rads); const offX = xy[0] + camData.xy[0]; const offY = xy[1] + camData.xy[1]; return [ - camData.zoom * (offX * cos - offY * sin), - camData.zoom * (offX * sin + offY * cos), + applyEpsilon(camData.zoom * (offX * cos - offY * sin)), + applyEpsilon(camData.zoom * (offX * sin + offY * cos)), ]; } } @@ -252,6 +259,20 @@ this.skin?.emitWasAltered(); }; + // Clones should inherit the parents camera + const ogInitDrawable = vm.exports.RenderedTarget.prototype.initDrawable; + vm.exports.RenderedTarget.prototype.initDrawable = function (layerGroup) { + ogInitDrawable.call(this, layerGroup); + if (this.isOriginal) return; + + const parentSprite = this.sprite.clones[0]; // clone[0] is always the original + const parentDrawable = render._allDrawables[parentSprite.drawableID]; + const name = parentDrawable[cameraSymbol]?.name ?? "default"; + + const drawable = render._allDrawables[this.drawableID]; + bindDrawable(drawable, name); + }; + // Turbowarp Extension Storage runtime.on("PROJECT_LOADED", () => { const stored = runtime.extensionStorage["SPcamera"]; @@ -700,10 +721,10 @@ } translateAngledMovement(xy, steps, direction) { - const radians = direction * (Math.PI / 180); + const radians = direction * radianConstant; return [ - xy[0] + steps * Math.cos(radians), - xy[1] + steps * Math.sin(radians), + applyEpsilon(xy[0] + steps * Math.cos(radians)), + applyEpsilon(xy[1] + steps * Math.sin(radians)), ]; } diff --git a/extensions/Skyhigh173/json.js b/extensions/Skyhigh173/json.js index 06b4eb4521..2ffdc58eeb 100644 --- a/extensions/Skyhigh173/json.js +++ b/extensions/Skyhigh173/json.js @@ -680,7 +680,7 @@ return json; } else { try { - return JSON.parse(json); + return JSON.parse(json) ?? ""; } catch { return json; } @@ -768,12 +768,14 @@ json = JSON.parse(json); switch (Stype) { case "keys": - return JSON.stringify(Object.keys(json).map((key) => key)); + return JSON.stringify(Object.keys(json).map((key) => key ?? "")); case "values": - return JSON.stringify(Object.keys(json).map((key) => json[key])); + return JSON.stringify( + Object.keys(json).map((key) => json[key] ?? "") + ); case "datas": return JSON.stringify( - Object.keys(json).map((key) => [key, json[key]]) + Object.keys(json).map((key) => [key, json[key] ?? ""]) ); default: return ""; @@ -787,7 +789,7 @@ try { json = JSON.parse(json); if (hasOwn(json, item)) { - const result = json[item]; + const result = json[item] ?? ""; if (typeof result === "object") { return JSON.stringify(result); } else { @@ -805,7 +807,8 @@ if (Number.isNaN(value)) return "NaN"; if (value === Infinity) return "Infinity"; if (value === -Infinity) return "-Infinity"; - return value; + // null and undefined -> empty + return value ?? ""; } json_set({ item, value, json }) { @@ -850,6 +853,7 @@ } else { result = json[json.length + item]; } + result = result ?? ""; if (typeof result == "object") { return JSON.stringify(result); } else { @@ -1032,7 +1036,7 @@ if (Array.isArray(array)) { const safeArray = array.map((i) => { if (typeof i === "object") return JSON.stringify(i); - return i; + return i ?? ""; }); listVariable.value = safeArray; } diff --git a/extensions/cloudlink.js b/extensions/cloudlink.js index bc1d524eee..68507f62a7 100644 --- a/extensions/cloudlink.js +++ b/extensions/cloudlink.js @@ -1,13 +1,10 @@ -// Name: Cloudlink +// Name: CloudLink V4 // ID: cloudlink // Description: A powerful WebSocket extension for Scratch. // By: MikeDEV // License: MIT -/* eslint-disable */ -// prettier-ignore (function (Scratch) { - /* CloudLink Extension for TurboWarp v0.1.2. @@ -37,9 +34,9 @@ */ // Require extension to be unsandboxed. - 'use strict'; + "use strict"; if (!Scratch.extensions.unsandboxed) { - throw new Error('The CloudLink extension must run unsandboxed.'); + throw new Error("The CloudLink extension must run unsandboxed."); } // Declare icons as static SVG URIs @@ -81,7 +78,6 @@ // Store extension state var clVars = { - // Editor-specific variable for hiding old, legacy-support blocks. hideCLDeprecatedBlocks: true, @@ -235,14 +231,24 @@ handshakeAttempted: false, // Storage for the publically available CloudLink instances. - serverList: {}, - } + serverList: { + 0: { + id: "Localhost", + url: "ws://127.0.0.1:3000/", + }, + 7: { + id: "MikeDEV's Spare CL 0.2.0 Server", + url: "wss://cl.mikedev101.cc/", + }, + }, + }; function generateVersionString() { return `${version.editorType} ${version.versionString}`; } // Makes values safe for Scratch to represent. + // eslint-disable-next-line require-await async function makeValueScratchSafe(data) { if (typeof data == "object") { try { @@ -328,12 +334,14 @@ function sendMessage(message) { // Prevent running this while disconnected if (clVars.socket == null) { - console.warn("[CloudLink] Ignoring attempt to send a packet while disconnected."); + console.warn( + "[CloudLink] Ignoring attempt to send a packet while disconnected." + ); return; } // See if the outgoing val argument can be converted into JSON - if (message.hasOwnProperty("val")) { + if (Object.prototype.hasOwnProperty.call(message, "val")) { try { message.val = JSON.parse(message.val); } catch {} @@ -341,28 +349,33 @@ // Attach listeners if (clVars.listeners.enablerState) { - // 0.1.8.x was the first server version to support listeners. if (clVars.linkState.identifiedProtocol >= 2) { message.listener = clVars.listeners.enablerValue; // Create listener - clVars.listeners.varStates[String(args.ID)] = { + clVars.listeners.varStates[message.listener] = { hasNew: false, varState: {}, eventHatTick: false, }; - } else { - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners." + ); } clVars.listeners.enablerState = false; } // Check if server supports rooms - if (((message.cmd == "link") || (message.cmd == "unlink")) && (clVars.linkState.identifiedProtocol < 2)) { + if ( + (message.cmd == "link" || message.cmd == "unlink") && + clVars.linkState.identifiedProtocol < 2 + ) { // 0.1.8.x was the first server version to support rooms. - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support room linking/unlinking."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support room linking/unlinking." + ); return; } @@ -371,7 +384,10 @@ try { outgoing = JSON.stringify(message); } catch (SyntaxError) { - console.warn("[CloudLink] Failed to send a packet, invalid syntax:", message); + console.warn( + "[CloudLink] Failed to send a packet, invalid syntax:", + message + ); return; } @@ -393,12 +409,13 @@ versionNumber: version.versionNumber, }, }, - listener: "handshake_cfg" + listener: "handshake_cfg", }); clVars.handshakeAttempted = true; } // Compare the version string of the server to known compatible variants to configure clVars.linkState.identifiedProtocol. + // eslint-disable-next-line require-await async function setServerVersion(version) { console.log(`[CloudLink] Server version: ${String(version)}`); clVars.server_version = version; @@ -413,47 +430,56 @@ "S2.2": 0, // 0.1.5 "0.1.": 0, // 0.1.5 or legacy "S2.": 0, // Legacy - "S1.": -1 // Obsolete + "S1.": -1, // Obsolete }; for (const [key, value] of Object.entries(versions)) { if (version.includes(key)) { if (clVars.linkState.identifiedProtocol < value) { - // Disconnect if protcol is too old if (value == -1) { - console.warn(`[CloudLink] Server is too old to enable leagacy support. Disconnecting.`); + console.warn( + `[CloudLink] Server is too old to enable leagacy support. Disconnecting.` + ); return clVars.socket.close(1000, ""); } - + // Set the identified protocol variant clVars.linkState.identifiedProtocol = value; } } - }; + } // Log configured spec version - console.log(`[CloudLink] Configured protocol spec to v${clVars.linkState.identifiedProtocol}.`); + console.log( + `[CloudLink] Configured protocol spec to v${clVars.linkState.identifiedProtocol}.` + ); // Fix timing bug clVars.linkState.status = 2; // Fire event hats (only one not broken) - runtime.startHats('cloudlink_onConnect'); + runtime.startHats("cloudlink_onConnect"); // Don't nag user if they already trusted this server if (clVars.currentServerUrl === clVars.lastServerUrl) return; // Ask user if they wish to stay connected if the server is unsupported - if ((clVars.linkState.identifiedProtocol < 4) && (!confirm( - `You have connected to an old CloudLink server, running version ${clVars.server_version}.\n\nFor your security and privacy, we recommend you disconnect from this server and connect to an up-to-date server.\n\nClick/tap \"OK\" to stay connected.` - ))) { + if ( + clVars.linkState.identifiedProtocol < 4 && + !confirm( + `You have connected to an old CloudLink server, running version ${clVars.server_version}.\n\nFor your security and privacy, we recommend you disconnect from this server and connect to an up-to-date server.\n\nClick/tap "OK" to stay connected.` + ) + ) { // Close the connection if they choose "Cancel" clVars.linkState.isAttemptingGracefulDisconnect = true; - clVars.socket.close(1000, "Client going away (legacy server rejected by end user)"); + clVars.socket.close( + 1000, + "Client going away (legacy server rejected by end user)" + ); return; } - + // Don't nag user the next time they connect to this server clVars.lastServerUrl = clVars.currentServerUrl; } @@ -463,15 +489,21 @@ // Parse the message JSON let packet = {}; try { - packet = JSON.parse(data) + packet = JSON.parse(data); } catch (SyntaxError) { - console.error("[CloudLink] Incoming message parse failure! Is this really a CloudLink server?", data); + console.error( + "[CloudLink] Incoming message parse failure! Is this really a CloudLink server?", + data + ); return; - }; + } // Handle packet commands - if (!packet.hasOwnProperty("cmd")) { - console.error("[CloudLink] Incoming message read failure! This message doesn't contain the required \"cmd\" key. Is this really a CloudLink server?", packet); + if (!Object.prototype.hasOwnProperty.call(packet, "cmd")) { + console.error( + '[CloudLink] Incoming message read failure! This message doesn\'t contain the required "cmd" key. Is this really a CloudLink server?', + packet + ); return; } console.log("[CloudLink] RX:", packet); @@ -512,7 +544,7 @@ case "direct": // Handle events from older server versions - if (packet.val.hasOwnProperty("cmd")) { + if (Object.prototype.hasOwnProperty.call(packet.val, "cmd")) { switch (packet.val.cmd) { // Server 0.1.5 (at least) case "vers": @@ -522,7 +554,9 @@ // Server 0.1.7 (at least) case "motd": - console.log(`[CloudLink] Message of the day: \"${packet.val.val}\"`); + console.log( + `[CloudLink] Message of the day: "${packet.val.val}"` + ); clVars.motd = packet.val.val; return; } @@ -544,7 +578,9 @@ // Store direct value // Protocol v0 (0.1.5 and legacy) don't implement status codes. if (clVars.linkState.identifiedProtocol == 0) { - console.warn("[CloudLink] Received a statuscode message while using protocol v0. This event shouldn't happen. It's likely that this server is modified (did MikeDEV overlook some unexpected behavior?)."); + console.warn( + "[CloudLink] Received a statuscode message while using protocol v0. This event shouldn't happen. It's likely that this server is modified (did MikeDEV overlook some unexpected behavior?)." + ); return; } @@ -556,30 +592,32 @@ // Protocol v2 (0.1.8.x) uses "code" instead. // Protocol v3-v4 (0.1.9.x - latest, 0.2.0) adds "code_id" to the payload. Ignored by Scratch clients. else { - // Handle setup listeners - if (packet.hasOwnProperty("listener")) { + if (Object.prototype.hasOwnProperty.call(packet, "listener")) { switch (packet.listener) { case "username_cfg": - // Username accepted if (packet.code.includes("I:100")) { clVars.myUserObject = packet.val; clVars.username.value = packet.val.username; clVars.username.accepted = true; - console.log(`[CloudLink] Username has been set to \"${clVars.username.value}\" successfully!`); + console.log( + `[CloudLink] Username has been set to "${clVars.username.value}" successfully!` + ); - // Username rejected / error + // Username rejected / error } else { - console.log(`[CloudLink] Username rejected by the server! Error code ${packet.code}.}`); + console.log( + `[CloudLink] Username rejected by the server! Error code ${packet.code}.}` + ); } return; - + case "handshake_cfg": // Prevent handshake responses being stored in the statuscode variables console.log("[CloudLink] Server responded to our handshake!"); return; - + case "link": // Room link accepted if (!clVars.rooms.isAttemptingLink) return; @@ -588,12 +626,14 @@ clVars.rooms.isLinked = true; console.log("[CloudLink] Room linked successfully!"); - // Room link rejected / error + // Room link rejected / error } else { - console.log(`[CloudLink] Room link rejected! Error code ${packet.code}.}`); + console.log( + `[CloudLink] Room link rejected! Error code ${packet.code}.}` + ); } return; - + case "unlink": // Room unlink accepted if (!clVars.rooms.isAttemptingUnlink) return; @@ -602,9 +642,11 @@ clVars.rooms.isLinked = false; console.log("[CloudLink] Room unlinked successfully!"); - // Room link rejected / error + // Room link rejected / error } else { - console.log(`[CloudLink] Room unlink rejected! Error code ${packet.code}.}`); + console.log( + `[CloudLink] Room unlink rejected! Error code ${packet.code}.}` + ); } return; } @@ -623,21 +665,25 @@ case "ulist": // Protocol v0-v1 (0.1.5 and legacy - 0.1.7) use a semicolon (;) separated string for the userlist. if ( - (clVars.linkState.identifiedProtocol == 0) - || - (clVars.linkState.identifiedProtocol == 1) + clVars.linkState.identifiedProtocol == 0 || + clVars.linkState.identifiedProtocol == 1 ) { // Split the username list string - clVars.ulist = String(packet.val).split(';'); + clVars.ulist = String(packet.val).split(";"); // Get rid of blank entry at the end of the list - clVars.ulist.pop(clVars.ulist.length); + clVars.ulist.pop(); // Check if username has been set (since older servers don't implement statuscodes or listeners) - if ((clVars.username.attempted) && (clVars.ulist.includes(clVars.username.temp))) { + if ( + clVars.username.attempted && + clVars.ulist.includes(clVars.username.temp) + ) { clVars.username.value = clVars.username.temp; clVars.username.accepted = true; - console.log(`[CloudLink] Username has been set to \"${clVars.username.value}\" successfully!`); + console.log( + `[CloudLink] Username has been set to "${clVars.username.value}" successfully!` + ); } } @@ -649,23 +695,27 @@ // Protocol v3-v4 (0.1.9.x - latest, 0.2.0) uses "mode" to add/set/remove entries to the userlist. else { // Check for "mode" key - if (!packet.hasOwnProperty("mode")) { - console.warn("[CloudLink] Userlist message did not specify \"mode\" while running in protocol mode 3 or 4."); + if (!Object.prototype.hasOwnProperty.call(packet, "mode")) { + console.warn( + '[CloudLink] Userlist message did not specify "mode" while running in protocol mode 3 or 4.' + ); return; - }; + } // Handle methods switch (packet.mode) { - case 'set': + case "set": clVars.ulist = packet.val; break; - case 'add': + case "add": clVars.ulist.push(packet.val); break; - case 'remove': + case "remove": clVars.ulist.slice(clVars.ulist.indexOf(packet.val), 1); break; default: - console.warn(`[CloudLink] Unrecognised userlist mode: \"${packet.mode}\".`); + console.warn( + `[CloudLink] Unrecognised userlist mode: "${packet.mode}".` + ); break; } } @@ -680,27 +730,28 @@ case "client_ip": console.log(`[CloudLink] Client IP address: ${packet.val}`); - console.warn("[CloudLink] This server has relayed your identified IP address to you. Under normal circumstances, this will be erased server-side when you disconnect, but you should still be careful. Unless you trust this server, it is not recommended to send login credentials or personal info."); + console.warn( + "[CloudLink] This server has relayed your identified IP address to you. Under normal circumstances, this will be erased server-side when you disconnect, but you should still be careful. Unless you trust this server, it is not recommended to send login credentials or personal info." + ); clVars.client_ip = packet.val; break; case "motd": - console.log(`[CloudLink] Message of the day: \"${packet.val}\"`); + console.log(`[CloudLink] Message of the day: "${packet.val}"`); clVars.motd = packet.val; break; default: - console.warn(`[CloudLink] Unrecognised command: \"${packet.cmd}\".`); + console.warn(`[CloudLink] Unrecognised command: "${packet.cmd}".`); return; } // Handle listeners - if (packet.hasOwnProperty("listener")) { + if (Object.prototype.hasOwnProperty.call(packet, "listener")) { if (clVars.listeners.current.includes(String(packet.listener))) { - // Remove the listener from the currently listening list clVars.listeners.current.splice( - clVars.listeners.current.indexOf(String(packet.listener)), + clVars.listeners.current.indexOf(String(packet.listener)), 1 ); @@ -717,7 +768,9 @@ // Basic netcode needed to make the extension work async function newClient(url) { if (!(await Scratch.canFetch(url))) { - console.warn("[CloudLink] Did not get permission to connect, aborting..."); + console.warn( + "[CloudLink] Did not get permission to connect, aborting..." + ); return; } @@ -728,6 +781,7 @@ // Establish a connection to the server console.log("[CloudLink] Connecting to server:", url); try { + // eslint-disable-next-line extension/check-can-fetch clVars.socket = new WebSocket(url); } catch (e) { console.warn("[CloudLink] An exception has occurred:", e); @@ -737,13 +791,15 @@ // Bind connection established event clVars.socket.onopen = function (event) { clVars.currentServerUrl = url; - + // Set the link state to connected. console.log("[CloudLink] Connected."); // If a server_version message hasn't been received in over half a second, try to broadcast a handshake - clVars.handshakeTimeout = window.setTimeout(function() { - console.log("[CloudLink] Hmm... This server hasn't sent us it's server info. Going to attempt a handshake."); + clVars.handshakeTimeout = window.setTimeout(function () { + console.log( + "[CloudLink] Hmm... This server hasn't sent us it's server info. Going to attempt a handshake." + ); sendHandshake(); }, 500); @@ -767,14 +823,21 @@ break; case 2: // Was already connected - if (event.wasClean || clVars.linkState.isAttemptingGracefulDisconnect) { + if ( + event.wasClean || + clVars.linkState.isAttemptingGracefulDisconnect + ) { // Set the link state to graceful disconnect. - console.log(`[CloudLink] Disconnected (${event.code} ${event.reason}).`); + console.log( + `[CloudLink] Disconnected (${event.code} ${event.reason}).` + ); clVars.linkState.status = 3; clVars.linkState.disconnectType = 0; } else { // Set the link state to ungraceful disconnect. - console.log(`[CloudLink] Lost connection (${event.code} ${event.reason}).`); + console.log( + `[CloudLink] Lost connection (${event.code} ${event.reason}).` + ); clVars.linkState.status = 4; clVars.linkState.disconnectType = 2; } @@ -785,59 +848,39 @@ resetOnClose(); // Run all onClose event blocks - runtime.startHats('cloudlink_onClose'); + runtime.startHats("cloudlink_onClose"); // Return promise (during setup) return; - } - } - - // GET the serverList - try { - Scratch.fetch( - "https://raw.githubusercontent.com/MikeDev101/cloudlink/master/serverlist.json" - ) - .then((response) => { - return response.text(); - }) - .then((data) => { - clVars.serverList = JSON.parse(data); - }) - .catch((err) => { - console.log("[CloudLink] An error has occurred while parsing the public server list:", err); - clVars.serverList = {}; - }); - } catch (err) { - console.log("[CloudLink] An error has occurred while fetching the public server list:", err); - clVars.serverList = {}; + }; } // Declare the CloudLink library. class CloudLink { getInfo() { return { - id: 'cloudlink', - name: 'CloudLink', + id: "cloudlink", + // eslint-disable-next-line extension/should-translate + name: "CloudLink V4", blockIconURI: cl_block, menuIconURI: cl_icon, docsURI: "https://github.com/MikeDev101/cloudlink/wiki/Scratch-Client", blocks: [ - { opcode: "returnGlobalData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("global data") + text: Scratch.translate("global data"), }, { opcode: "returnPrivateData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("private data") + text: Scratch.translate("private data"), }, { opcode: "returnDirectData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("direct data") + text: Scratch.translate("direct data"), }, "---", @@ -845,13 +888,13 @@ { opcode: "returnLinkData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("link status") + text: Scratch.translate("link status"), }, { opcode: "returnStatusCode", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("status code") + text: Scratch.translate("status code"), }, "---", @@ -859,20 +902,22 @@ { opcode: "returnUserListData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("usernames") + text: Scratch.translate("usernames"), }, { opcode: "returnUsernameDataNew", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("my username") + text: Scratch.translate("my username"), }, { opcode: "returnUsernameData", blockType: Scratch.BlockType.REPORTER, hideFromPalette: clVars.hideCLDeprecatedBlocks, - text: Scratch.translate("(OLD - DO NOT USE IN NEW PROJECTS) my username") + text: Scratch.translate( + "(OLD - DO NOT USE IN NEW PROJECTS) my username" + ), }, "---", @@ -880,25 +925,25 @@ { opcode: "returnVersionData", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("extension version") + text: Scratch.translate("extension version"), }, { opcode: "returnServerVersion", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("server version") + text: Scratch.translate("server version"), }, { opcode: "returnServerList", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("server list") + text: Scratch.translate("server list"), }, { opcode: "returnMOTD", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("server MOTD") + text: Scratch.translate("server MOTD"), }, "---", @@ -906,13 +951,13 @@ { opcode: "returnClientIP", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("my IP address") + text: Scratch.translate("my IP address"), }, { opcode: "returnUserObject", blockType: Scratch.BlockType.REPORTER, - text: Scratch.translate("my user object") + text: Scratch.translate("my user object"), }, "---", @@ -986,11 +1031,12 @@ arguments: { PATH: { type: Scratch.ArgumentType.STRING, - defaultValue: 'fruit/apples', + defaultValue: "fruit/apples", }, JSON_STRING: { type: Scratch.ArgumentType.STRING, - defaultValue: '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + defaultValue: + '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', }, }, }, @@ -999,7 +1045,7 @@ opcode: "getFromJSONArray", blockType: Scratch.BlockType.REPORTER, hideFromPalette: clVars.hideCLDeprecatedBlocks, - text: Scratch.translate('[NUM] from JSON array [ARRAY]'), + text: Scratch.translate("[NUM] from JSON array [ARRAY]"), arguments: { NUM: { type: Scratch.ArgumentType.NUMBER, @@ -1008,8 +1054,8 @@ ARRAY: { type: Scratch.ArgumentType.STRING, defaultValue: '["foo","bar"]', - } - } + }, + }, }, { @@ -1033,12 +1079,12 @@ arguments: { JSON_STRING: { type: Scratch.ArgumentType.STRING, - defaultValue: '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', + defaultValue: + '{"fruit": {"apples": 2, "bananas": 3}, "total_fruit": 5}', }, }, }, - "---", { @@ -1058,7 +1104,9 @@ opcode: "requestURL", blockType: Scratch.BlockType.REPORTER, hideFromPalette: clVars.hideCLDeprecatedBlocks, - text: Scratch.translate("send request with method [method] for URL [url] with data [data] and headers [headers]"), + text: Scratch.translate( + "send request with method [method] for URL [url] with data [data] and headers [headers]" + ), arguments: { method: { type: Scratch.ArgumentType.STRING, @@ -1100,7 +1148,9 @@ { opcode: "onListener", blockType: Scratch.BlockType.HAT, - text: Scratch.translate("when I receive new message with listener [ID]"), + text: Scratch.translate( + "when I receive new message with listener [ID]" + ), isEdgeActivated: true, arguments: { ID: { @@ -1237,9 +1287,9 @@ arguments: { IP: { type: Scratch.ArgumentType.STRING, - defaultValue: "ws://127.0.0.1:3000/", - } - } + defaultValue: "wss://cl.mikedev101.cc/", + }, + }, }, { @@ -1249,15 +1299,15 @@ arguments: { ID: { type: Scratch.ArgumentType.NUMBER, - defaultValue: 1, - } - } + defaultValue: "7", + }, + }, }, { opcode: "closeSocket", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("disconnect") + text: Scratch.translate("disconnect"), }, "---", @@ -1286,13 +1336,12 @@ defaultValue: "example-listener", }, }, - }, "---", { - opcode: 'linkToRooms', + opcode: "linkToRooms", blockType: Scratch.BlockType.COMMAND, text: Scratch.translate("link to room(s) [ROOMS]"), arguments: { @@ -1300,7 +1349,7 @@ type: Scratch.ArgumentType.STRING, defaultValue: Scratch.translate('["test"]'), }, - } + }, }, { @@ -1370,7 +1419,9 @@ { opcode: "sendPDataAsVar", blockType: Scratch.BlockType.COMMAND, - text: Scratch.translate("send variable [VAR] to [ID] with data [DATA]"), + text: Scratch.translate( + "send variable [VAR] to [ID] with data [DATA]" + ), arguments: { DATA: { type: Scratch.ArgumentType.STRING, @@ -1485,7 +1536,7 @@ menu: "allmenu", defaultValue: "All data", }, - } + }, }, "---", @@ -1505,45 +1556,71 @@ }, "---", - ], menus: { datamenu: { items: [ - {text: Scratch.translate('Global data'), value: 'Global data'}, - {text: Scratch.translate('Private data'), value: 'Private data'}, - {text: Scratch.translate('Direct data'), value: 'Direct data'}, - {text: Scratch.translate('Status code'), value: 'Status code'} - ] + { text: Scratch.translate("Global data"), value: "Global data" }, + { + text: Scratch.translate("Private data"), + value: "Private data", + }, + { text: Scratch.translate("Direct data"), value: "Direct data" }, + { text: Scratch.translate("Status code"), value: "Status code" }, + ], }, varmenu: { items: [ - {text: Scratch.translate('Global variables'), value: "Global variables"}, - {text: Scratch.translate('Private variables'), value: "Private variables"} - ] + { + text: Scratch.translate("Global variables"), + value: "Global variables", + }, + { + text: Scratch.translate("Private variables"), + value: "Private variables", + }, + ], }, allmenu: { items: [ - {text: Scratch.translate('Global data'), value: 'Global data'}, - {text: Scratch.translate('Private data'), value: 'Private data'}, - {text: Scratch.translate('Direct data'), value: 'Direct data'}, - {text: Scratch.translate('Status code'), value: 'Status code'}, - {text: Scratch.translate("Global variables"), value: "Global variables"}, - {text: Scratch.translate("Private variables"), value: "Private variables"}, - {text: Scratch.translate("All data"), value: "All data"} - ] + { text: Scratch.translate("Global data"), value: "Global data" }, + { + text: Scratch.translate("Private data"), + value: "Private data", + }, + { text: Scratch.translate("Direct data"), value: "Direct data" }, + { text: Scratch.translate("Status code"), value: "Status code" }, + { + text: Scratch.translate("Global variables"), + value: "Global variables", + }, + { + text: Scratch.translate("Private variables"), + value: "Private variables", + }, + { text: Scratch.translate("All data"), value: "All data" }, + ], }, almostallmenu: { items: [ - {text: Scratch.translate('Global data'), value: 'Global data'}, - {text: Scratch.translate('Private data'), value: 'Private data'}, - {text: Scratch.translate('Direct data'), value: 'Direct data'}, - {text: Scratch.translate('Status code'), value: 'Status code'}, - {text: Scratch.translate("Global variables"), value: "Global variables"}, - {text: Scratch.translate("Private variables"), value: "Private variables"} - ] + { text: Scratch.translate("Global data"), value: "Global data" }, + { + text: Scratch.translate("Private data"), + value: "Private data", + }, + { text: Scratch.translate("Direct data"), value: "Direct data" }, + { text: Scratch.translate("Status code"), value: "Status code" }, + { + text: Scratch.translate("Global variables"), + value: "Global variables", + }, + { + text: Scratch.translate("Private variables"), + value: "Private variables", + }, + ], }, - } + }, }; } @@ -1638,7 +1715,12 @@ // Reporter - Returns data for a specific listener ID. // ID - String (listener ID) returnListenerData(args) { - if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + if ( + !Object.prototype.hasOwnProperty.call( + clVars.listeners.varStates, + String(args.ID) + ) + ) { console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); return ""; } @@ -1649,19 +1731,19 @@ // TYPE - String (menu allmenu) readQueueSize(args) { switch (args.TYPE) { - case 'Global data': + case "Global data": return clVars.gmsg.queue.length; - case 'Private data': + case "Private data": return clVars.pmsg.queue.length; - case 'Direct data': + case "Direct data": return clVars.direct.queue.length; - case 'Status code': + case "Status code": return clVars.statuscode.queue.length; - case 'Global variables': + case "Global variables": return clVars.gvar.queue.length; - case 'Private variables': + case "Private variables": return clVars.pvar.queue.length; - case 'All data': + case "All data": return ( clVars.gmsg.queue.length + clVars.pmsg.queue.length + @@ -1677,26 +1759,26 @@ // TYPE - String (menu allmenu) readQueueData(args) { switch (args.TYPE) { - case 'Global data': + case "Global data": return makeValueScratchSafe(clVars.gmsg.queue); - case 'Private data': + case "Private data": return makeValueScratchSafe(clVars.pmsg.queue); - case 'Direct data': + case "Direct data": return makeValueScratchSafe(clVars.direct.queue); - case 'Status code': + case "Status code": return makeValueScratchSafe(clVars.statuscode.queue); - case 'Global variables': + case "Global variables": return makeValueScratchSafe(clVars.gvar.queue); - case 'Private variables': + case "Private variables": return makeValueScratchSafe(clVars.pvar.queue); - case 'All data': + case "All data": return makeValueScratchSafe({ gmsg: clVars.gmsg.queue, pmsg: clVars.pmsg.queue, direct: clVars.direct.queue, statuscode: clVars.statuscode.queue, gvar: clVars.gvar.queue, - pvar: clVars.pvar.queue + pvar: clVars.pvar.queue, }); } } @@ -1705,42 +1787,58 @@ // TYPE - String (menu varmenu), VAR - String (variable name) returnVarData(args) { switch (args.TYPE) { - case 'Global variables': - if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); - return ""; - } - return clVars.gvar.varStates[String(args.VAR)].varState; - case 'Private variables': - if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); - return ""; - } - return clVars.pvar.varStates[String(args.VAR)].varState; + case "Global variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.gvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Global variable ${args.VAR} does not exist!` + ); + return ""; + } + return clVars.gvar.varStates[String(args.VAR)].varState; + case "Private variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.pvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Private variable ${args.VAR} does not exist!` + ); + return ""; + } + return clVars.pvar.varStates[String(args.VAR)].varState; } } // Reporter - Gets a JSON key value from a JSON string. // PATH - String, JSON_STRING - String - parseJSON(args) { + parseJSON(args) { try { - const path = args.PATH.toString().split('/').map(prop => decodeURIComponent(prop)); - if (path[0] === '') path.splice(0, 1); - if (path[path.length - 1] === '') path.splice(-1, 1); + const path = args.PATH.toString() + .split("/") + .map((prop) => decodeURIComponent(prop)); + if (path[0] === "") path.splice(0, 1); + if (path[path.length - 1] === "") path.splice(-1, 1); let json; try { - json = JSON.parse(' ' + args.JSON_STRING); + json = JSON.parse(" " + args.JSON_STRING); } catch (e) { return e.message; - }; - path.forEach(prop => json = json[prop]); - if (json === null) return 'null'; - else if (json === undefined) return ''; - else if (typeof json === 'object') return JSON.stringify(json); + } + path.forEach((prop) => (json = json[prop])); + if (json === null) return "null"; + else if (json === undefined) return ""; + else if (typeof json === "object") return JSON.stringify(json); else return json.toString(); } catch (err) { - return ''; - }; + return ""; + } } // Reporter - Returns an entry from a JSON array (0-based). @@ -1751,11 +1849,11 @@ return ""; } else { let data = json_array[args.NUM]; - - if (typeof (data) == "object") { + + if (typeof data == "object") { data = JSON.stringify(data); // Make the JSON safe for Scratch } - + return data; } } @@ -1763,9 +1861,9 @@ // Reporter - Returns a RESTful GET promise. // url - String fetchURL(args) { - return Scratch.fetch(args.url, {method: "GET"}) - .then(response => response.text()) - .catch(error => { + return Scratch.fetch(args.url, { method: "GET" }) + .then((response) => response.text()) + .catch((error) => { console.warn(`[CloudLink] Fetch error: ${error}`); }); } @@ -1776,25 +1874,25 @@ if (args.method == "GET" || args.method == "HEAD") { return Scratch.fetch(args.url, { method: args.method, - headers: JSON.parse(args.headers) + headers: JSON.parse(args.headers), }) - .then(response => response.text()) - .catch(error => { + .then((response) => response.text()) + .catch((error) => { console.warn(`[CloudLink] Request error: ${error}`); }); } else { return Scratch.fetch(args.url, { method: args.method, headers: JSON.parse(args.headers), - body: JSON.parse(args.data) + body: JSON.parse(args.data), }) - .then(response => response.text()) - .catch(error => { + .then((response) => response.text()) + .catch((error) => { console.warn(`[CloudLink] Request error: ${error}`); }); } } - + // Event // ID - String (listener) onListener(args) { @@ -1803,7 +1901,13 @@ if (clVars.linkState.status != 2) return false; // Listener must exist - if (!clVars.listeners.varStates.hasOwnProperty(args.ID)) return false; + if ( + !Object.prototype.hasOwnProperty.call( + clVars.listeners.varStates, + args.ID + ) + ) + return false; // Run event if (clVars.listeners.varStates[args.ID].eventHatTick) { @@ -1822,42 +1926,42 @@ // Run event switch (args.TYPE) { - case 'Global data': + case "Global data": if (clVars.gmsg.eventHatTick) { clVars.gmsg.eventHatTick = false; return true; } break; - case 'Private data': + case "Private data": if (clVars.pmsg.eventHatTick) { clVars.pmsg.eventHatTick = false; return true; } break; - case 'Direct data': + case "Direct data": if (clVars.direct.eventHatTick) { clVars.direct.eventHatTick = false; return true; } break; - case 'Status code': + case "Status code": if (clVars.statuscode.eventHatTick) { clVars.statuscode.eventHatTick = false; return true; } break; - case 'Global variables': + case "Global variables": if (clVars.gvar.eventHatTick) { clVars.gvar.eventHatTick = false; return true; } break; - case 'Private variables': + case "Private variables": if (clVars.pvar.eventHatTick) { clVars.pvar.eventHatTick = false; return true; @@ -1876,10 +1980,15 @@ // Run event switch (args.TYPE) { - case 'Global variables': - + case "Global variables": // Variable must exist - if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) break; + if ( + !Object.prototype.hasOwnProperty.call( + clVars.gvar.varStates, + String(args.VAR) + ) + ) + break; if (clVars.gvar.varStates[String(args.VAR)].eventHatTick) { clVars.gvar.varStates[String(args.VAR)].eventHatTick = false; return true; @@ -1887,10 +1996,15 @@ break; - case 'Private variables': - + case "Private variables": // Variable must exist - if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) break; + if ( + !Object.prototype.hasOwnProperty.call( + clVars.pvar.varStates, + String(args.VAR) + ) + ) + break; if (clVars.pvar.varStates[String(args.VAR)].eventHatTick) { clVars.pvar.varStates[String(args.VAR)].eventHatTick = false; return true; @@ -1904,61 +2018,64 @@ // Reporter - Returns a JSON-ified value. // toBeJSONified - String makeJSON(args) { - if (typeof(args.toBeJSONified) == "string") { + if (typeof args.toBeJSONified == "string") { try { JSON.parse(args.toBeJSONified); return String(args.toBeJSONified); - } catch(err) { + } catch (err) { return "Not JSON!"; } - } else if (typeof(args.toBeJSONified) == "object") { + } else if (typeof args.toBeJSONified == "object") { return JSON.stringify(args.toBeJSONified); } else { return "Not JSON!"; - }; + } } // Boolean - Returns true if connected. getComState() { - return ((clVars.linkState.status == 2) && (clVars.socket != null)); + return clVars.linkState.status == 2 && clVars.socket != null; } // Boolean - Returns true if linked to rooms (other than "default") getRoomState() { - return ((clVars.socket != null) && (clVars.rooms.isLinked)); + return clVars.socket != null && clVars.rooms.isLinked; } // Boolean - Returns true if the connection was dropped. getComLostConnectionState() { - return ((clVars.linkState.status == 4) && (clVars.linkState.disconnectType == 2)); + return ( + clVars.linkState.status == 4 && clVars.linkState.disconnectType == 2 + ); } // Boolean - Returns true if the client failed to establish a connection. getComFailedConnectionState() { - return ((clVars.linkState.status == 4) && (clVars.linkState.disconnectType == 1)); + return ( + clVars.linkState.status == 4 && clVars.linkState.disconnectType == 1 + ); } // Boolean - Returns true if the username was set successfully. getUsernameState() { - return ((clVars.socket != null) && (clVars.username.accepted)); + return clVars.socket != null && clVars.username.accepted; } // Boolean - Returns true if there is new gmsg/pmsg/direct/statuscode data. // TYPE - String (menu datamenu) returnIsNewData(args) { - // Must be connected if (clVars.socket == null) return false; // Run event switch (args.TYPE) { - case 'Global data': + case "Global data": return clVars.gmsg.hasNew; - case 'Private data': + case "Private data": return clVars.pmsg.hasNew; - case 'Direct data': + case "Direct data": return clVars.direct.hasNew; - case 'Status code': + case "Status code": return clVars.statuscode.hasNew; } } @@ -1967,15 +2084,29 @@ // TYPE - String (menu varmenu), VAR - String (variable name) returnIsNewVarData(args) { switch (args.TYPE) { - case 'Global variables': - if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); + case "Global variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.gvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Global variable ${args.VAR} does not exist!` + ); return false; } return clVars.gvar.varStates[String(args.ID)].hasNew; - case 'Private variables': - if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); + case "Private variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.pvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Private variable ${args.VAR} does not exist!` + ); return false; } return clVars.pvar.varStates[String(args.ID)].hasNew; @@ -1985,7 +2116,12 @@ // Boolean - Returns true if a listener has a new value. // ID - String (listener ID) returnIsNewListener(args) { - if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + if ( + !Object.prototype.hasOwnProperty.call( + clVars.listeners.varStates, + String(args.ID) + ) + ) { console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); return false; } @@ -1995,37 +2131,34 @@ // Boolean - Returns true if a username/ID/UUID/object exists in the userlist. // ID - String (username or user object) checkForID(args) { - // Legacy ulist handling if (clVars.ulist.includes(args.ID)) return true; // New ulist handling if (clVars.linkState.identifiedProtocol > 2) { if (this.isValidJSON(args.ID)) { - return clVars.ulist.some(o => ( - (o.username === JSON.parse(args.ID).username) - && - (o.id == JSON.parse(args.ID).id) - )); + return clVars.ulist.some( + (o) => + o.username === JSON.parse(args.ID).username && + o.id == JSON.parse(args.ID).id + ); } else { - return clVars.ulist.some(o => ( - (o.username === String(args.ID)) - || - (o.id == args.ID) - )); + return clVars.ulist.some( + (o) => o.username === String(args.ID) || o.id == args.ID + ); } } else return false; } // Boolean - Returns true if the input JSON is valid. // JSON_STRING - String - isValidJSON(args) { + isValidJSON(args) { try { JSON.parse(args.JSON_STRING); return true; } catch { return false; - }; + } } // Command - Establishes a connection to a server. @@ -2034,7 +2167,7 @@ if (clVars.socket != null) { console.warn("[CloudLink] Already connected to a server."); return; - }; + } return newClient(args.IP); } @@ -2044,11 +2177,16 @@ if (clVars.socket != null) { console.warn("[CloudLink] Already connected to a server."); return; - }; - if (!clVars.serverList.hasOwnProperty(String(args.ID))) { + } + if ( + !Object.prototype.hasOwnProperty.call( + clVars.serverList, + String(args.ID) + ) + ) { console.warn("[CloudLink] Not a valid server ID!"); return; - }; + } return newClient(clVars.serverList[String(args.ID)]["url"]); } @@ -2057,7 +2195,7 @@ if (clVars.socket == null) { console.warn("[CloudLink] Already disconnected."); return; - }; + } console.log("[CloudLink] Disconnecting..."); clVars.linkState.isAttemptingGracefulDisconnect = true; clVars.socket.close(1000, "Client going away"); @@ -2073,13 +2211,13 @@ if (clVars.username.attempted) { console.warn("[CloudLink] Already attempting to set username!"); return; - }; + } // Prevent running if the username is already set. if (clVars.username.accepted) { console.warn("[CloudLink] Already set username!"); return; - }; + } // Update state clVars.username.attempted = true; @@ -2092,28 +2230,31 @@ // Command - Prepares the next transmitted message to have a listener ID attached to it. // ID - String (listener ID) createListener(args) { - // Must be connected to set a username. if (clVars.socket == null) return; - + // Require server support if (clVars.linkState.identifiedProtocol < 2) { - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support listeners." + ); return; } // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before creating a listener!"); + console.warn( + "[CloudLink] Username must be set before creating a listener!" + ); return; - }; + } // Must be used once per packet if (clVars.listeners.enablerState) { console.warn("[CloudLink] Cannot create multiple listeners at a time!"); return; } - + // Update state clVars.listeners.enablerState = true; clVars.listeners.enablerValue = args.ID; @@ -2121,34 +2262,37 @@ // Command - Subscribes to various rooms on a server. // ROOMS - String (JSON Array or single string) - linkToRooms(args) { - + linkToRooms(args) { // Must be connected to set a username. if (clVars.socket == null) return; // Require server support if (clVars.linkState.identifiedProtocol < 2) { - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms." + ); return; } // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before linking to rooms!"); + console.warn( + "[CloudLink] Username must be set before linking to rooms!" + ); return; - }; + } // Prevent running if already linked. if (clVars.rooms.isLinked) { console.warn("[CloudLink] Already linked to rooms!"); return; - }; + } // Prevent running if a room link is in progress. if (clVars.rooms.isAttemptingLink) { console.warn("[CloudLink] Currently linking to rooms! Please wait!"); return; - }; + } clVars.rooms.isAttemptingLink = true; sendMessage({ cmd: "link", val: args.ROOMS, listener: "link" }); @@ -2156,68 +2300,80 @@ // Command - Specifies specific subscribed rooms to transmit messages to. // ROOMS - String (JSON Array or single string) - selectRoomsInNextPacket(args) { - + selectRoomsInNextPacket(args) { // Must be connected to user rooms. if (clVars.socket == null) return; // Require server support if (clVars.linkState.identifiedProtocol < 2) { - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms." + ); return; } // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before selecting rooms!"); + console.warn( + "[CloudLink] Username must be set before selecting rooms!" + ); return; - }; + } // Require once per packet if (clVars.rooms.enablerState) { - console.warn("[CloudLink] Cannot use the room selector more than once at a time!"); + console.warn( + "[CloudLink] Cannot use the room selector more than once at a time!" + ); return; } // Prevent running if not linked. if (!clVars.rooms.isLinked) { - console.warn("[CloudLink] Cannot use room selector while not linked to rooms!"); + console.warn( + "[CloudLink] Cannot use room selector while not linked to rooms!" + ); return; - }; + } clVars.rooms.enablerState = true; clVars.rooms.enablerValue = args.ROOMS; - } + } // Command - Unsubscribes from all rooms and re-subscribes to the the "default" room on the server. - unlinkFromRooms() { - + unlinkFromRooms() { // Must be connected to user rooms. if (clVars.socket == null) return; // Require server support if (clVars.linkState.identifiedProtocol < 2) { - console.warn("[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms."); + console.warn( + "[CloudLink] Server is too old! Must be at least 0.1.8.x to support rooms." + ); return; } // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before unjoining rooms!"); + console.warn( + "[CloudLink] Username must be set before unjoining rooms!" + ); return; - }; + } // Prevent running if already unlinked. if (!clVars.rooms.isLinked) { console.warn("[CloudLink] Already unlinked from rooms!"); return; - }; + } // Prevent running if a room unlink is in progress. if (clVars.rooms.isAttemptingUnlink) { - console.warn("[CloudLink] Currently unlinking from rooms! Please wait!"); + console.warn( + "[CloudLink] Currently unlinking from rooms! Please wait!" + ); return; - }; + } clVars.rooms.isAttemptingUnlink = true; sendMessage({ cmd: "unlink", val: "", listener: "unlink" }); @@ -2226,7 +2382,6 @@ // Command - Sends a gmsg value. // DATA - String sendGData(args) { - // Must be connected. if (clVars.socket == null) return; @@ -2236,15 +2391,16 @@ // Command - Sends a pmsg value. // DATA - String, ID - String (recipient ID) sendPData(args) { - // Must be connected. if (clVars.socket == null) return; // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before sending private messages!"); + console.warn( + "[CloudLink] Username must be set before sending private messages!" + ); return; - }; + } sendMessage({ cmd: "pmsg", val: args.DATA, id: args.ID }); } @@ -2252,7 +2408,6 @@ // Command - Sends a gvar value. // DATA - String, VAR - String (variable name) sendGDataAsVar(args) { - // Must be connected. if (clVars.socket == null) return; @@ -2262,15 +2417,16 @@ // Command - Sends a pvar value. // DATA - String, VAR - String (variable name), ID - String (recipient ID) sendPDataAsVar(args) { - // Must be connected. if (clVars.socket == null) return; // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before sending private variables!"); + console.warn( + "[CloudLink] Username must be set before sending private variables!" + ); return; - }; + } sendMessage({ cmd: "pvar", val: args.DATA, name: args.VAR, id: args.ID }); } @@ -2278,7 +2434,6 @@ // Command - Sends a raw-format command without specifying an ID. // CMD - String (command), DATA - String runCMDnoID(args) { - // Must be connected. if (clVars.socket == null) return; @@ -2288,15 +2443,16 @@ // Command - Sends a raw-format command with an ID. // CMD - String (command), DATA - String, ID - String (recipient ID) runCMD(args) { - // Must be connected. if (clVars.socket == null) return; // Prevent running if the username hasn't been set. if (!clVars.username.accepted) { - console.warn("[CloudLink] Username must be set before using this command!"); + console.warn( + "[CloudLink] Username must be set before using this command!" + ); return; - }; + } sendMessage({ cmd: args.CMD, val: args.DATA, id: args.ID }); } @@ -2305,16 +2461,16 @@ // TYPE - String (menu datamenu) resetNewData(args) { switch (args.TYPE) { - case 'Global data': + case "Global data": clVars.gmsg.hasNew = false; break; - case 'Private data': + case "Private data": clVars.pmsg.hasNew = false; break; - case 'Direct data': + case "Direct data": clVars.direct.hasNew = false; break; - case 'Status code': + case "Status code": clVars.statuscode.hasNew = false; break; } @@ -2324,25 +2480,46 @@ // TYPE - String (menu varmenu), VAR - String (variable name) resetNewVarData(args) { switch (args.TYPE) { - case 'Global variables': - if (!clVars.gvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Global variable ${args.VAR} does not exist!`); + case "Global variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.gvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Global variable ${args.VAR} does not exist!` + ); return; } clVars.gvar.varStates[String(args.ID)].hasNew = false; - case 'Private variables': - if (!clVars.pvar.varStates.hasOwnProperty(String(args.VAR))) { - console.warn(`[CloudLink] Private variable ${args.VAR} does not exist!`); + break; + case "Private variables": + if ( + !Object.prototype.hasOwnProperty.call( + clVars.pvar.varStates, + String(args.VAR) + ) + ) { + console.warn( + `[CloudLink] Private variable ${args.VAR} does not exist!` + ); return false; } clVars.pvar.varStates[String(args.ID)].hasNew = false; + break; } } // Command - Resets the "returnIsNewListener" boolean state. // ID - Listener ID resetNewListener(args) { - if (!clVars.listeners.varStates.hasOwnProperty(String(args.ID))) { + if ( + !Object.prototype.hasOwnProperty.call( + clVars.listeners.varStates, + String(args.ID) + ) + ) { console.warn(`[CloudLink] Listener ID ${args.ID} does not exist!`); return; } @@ -2353,25 +2530,25 @@ // TYPE - String (menu allmenu) clearAllPackets(args) { switch (args.TYPE) { - case 'Global data': + case "Global data": clVars.gmsg.queue = []; break; - case 'Private data': + case "Private data": clVars.pmsg.queue = []; break; - case 'Direct data': + case "Direct data": clVars.direct.queue = []; break; - case 'Status code': + case "Status code": clVars.statuscode.queue = []; break; - case 'Global variables': + case "Global variables": clVars.gvar.queue = []; break; - case 'Private variables': + case "Private variables": clVars.pvar.queue = []; break; - case 'All data': + case "All data": clVars.gmsg.queue = []; clVars.pmsg.queue = []; clVars.direct.queue = []; diff --git a/extensions/extensions.json b/extensions/extensions.json index d8ed71e33d..0ffbe65d45 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -31,6 +31,7 @@ "Lily/LooksPlus", "Lily/MoreEvents", "Lily/ListTools", + "midi/midi", "veggiecan/mobilekeyboard", "NexusKitten/moremotion", "CubesterYT/WindowControls", @@ -48,6 +49,7 @@ "ar", "encoding", "Lily/SoundExpanded", + "midi/midi", "Lily/TempVariables2", "Lily/MoreTimers", "clouddata-ping", diff --git a/extensions/midi/midi.js b/extensions/midi/midi.js new file mode 100644 index 0000000000..d056df544f --- /dev/null +++ b/extensions/midi/midi.js @@ -0,0 +1,2867 @@ +// Name: MIDI +// ID: extmidi +// Description: An extension to use the WebMidi API for midi input/output. +// By: lselden +// By: CHCAT1320 +// License: MPL-2.0 + +(function (Scratch) { + "use strict"; + + if (!Scratch.extensions.unsandboxed) { + throw new Error("Midi must be run unsandboxed"); + } + + const EXT_ID = "extmidi"; + + //#region midi files + const Midi = (function initMidi() { + const exports = {}; + const module = { exports }; + + /*! + The followig code is from tonejs/midi, which is based on midi-file + The original code is available at https://github.com/Tonejs/Midi and https://github.com/carter-thaxton/midi-file + + We use it under the following license: + + The MIT License + + @tonejs/midi - Copyright © 2016 Yotam Mann + midi-file - Copyright © 2016 Carter Thaxton + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + + /* eslint-disable */ + // prettier-ignore + !function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var r=e();for(var n in r)("object"==typeof exports?exports:t)[n]=r[n]}}("undefined"!=typeof self?self:this,(function(){return(()=>{var t={507:(t,e,r)=>{"use strict";function n(t){var e=[];return i(t,e),e}function i(t,e){for(var r=0;rn})},289:(t,e,r)=>{e.parseMidi=r(666),e.writeMidi=r(865)},666:t=>{function e(t){for(var e,n=new r(t),i=[];!n.eof();){var a=o();i.push(a)}return i;function o(){var t={};t.deltaTime=n.readVarInt();var r=n.readUInt8();if(240==(240&r)){if(255!==r){if(240==r)return t.type="sysEx",a=n.readVarInt(),t.data=n.readBytes(a),t;if(247==r)return t.type="endSysEx",a=n.readVarInt(),t.data=n.readBytes(a),t;throw"Unrecognised MIDI event type byte: "+r}t.meta=!0;var i=n.readUInt8(),a=n.readVarInt();switch(i){case 0:if(t.type="sequenceNumber",2!==a)throw"Expected length for sequenceNumber event is 2, got "+a;return t.number=n.readUInt16(),t;case 1:return t.type="text",t.text=n.readString(a),t;case 2:return t.type="copyrightNotice",t.text=n.readString(a),t;case 3:return t.type="trackName",t.text=n.readString(a),t;case 4:return t.type="instrumentName",t.text=n.readString(a),t;case 5:return t.type="lyrics",t.text=n.readString(a),t;case 6:return t.type="marker",t.text=n.readString(a),t;case 7:return t.type="cuePoint",t.text=n.readString(a),t;case 32:if(t.type="channelPrefix",1!=a)throw"Expected length for channelPrefix event is 1, got "+a;return t.channel=n.readUInt8(),t;case 33:if(t.type="portPrefix",1!=a)throw"Expected length for portPrefix event is 1, got "+a;return t.port=n.readUInt8(),t;case 47:if(t.type="endOfTrack",0!=a)throw"Expected length for endOfTrack event is 0, got "+a;return t;case 81:if(t.type="setTempo",3!=a)throw"Expected length for setTempo event is 3, got "+a;return t.microsecondsPerBeat=n.readUInt24(),t;case 84:if(t.type="smpteOffset",5!=a)throw"Expected length for smpteOffset event is 5, got "+a;var o=n.readUInt8();return t.frameRate={0:24,32:25,64:29,96:30}[96&o],t.hour=31&o,t.min=n.readUInt8(),t.sec=n.readUInt8(),t.frame=n.readUInt8(),t.subFrame=n.readUInt8(),t;case 88:if(t.type="timeSignature",4!=a)throw"Expected length for timeSignature event is 4, got "+a;return t.numerator=n.readUInt8(),t.denominator=1<>4;switch(t.channel=15&r,c){case 8:return t.type="noteOff",t.noteNumber=s,t.velocity=n.readUInt8(),t;case 9:var u=n.readUInt8();return t.type=0===u?"noteOff":"noteOn",t.noteNumber=s,t.velocity=u,0===u&&(t.byte9=!0),t;case 10:return t.type="noteAftertouch",t.noteNumber=s,t.amount=n.readUInt8(),t;case 11:return t.type="controller",t.controllerType=s,t.value=n.readUInt8(),t;case 12:return t.type="programChange",t.programNumber=s,t;case 13:return t.type="channelAftertouch",t.amount=s,t;case 14:return t.type="pitchBend",t.value=s+(n.readUInt8()<<7)-8192,t;default:throw"Unrecognised MIDI event type: "+c}}}}function r(t){this.buffer=t,this.bufferLen=this.buffer.length,this.pos=0}r.prototype.eof=function(){return this.pos>=this.bufferLen},r.prototype.readUInt8=function(){var t=this.buffer[this.pos];return this.pos+=1,t},r.prototype.readInt8=function(){var t=this.readUInt8();return 128&t?t-256:t},r.prototype.readUInt16=function(){return(this.readUInt8()<<8)+this.readUInt8()},r.prototype.readInt16=function(){var t=this.readUInt16();return 32768&t?t-65536:t},r.prototype.readUInt24=function(){return(this.readUInt8()<<16)+(this.readUInt8()<<8)+this.readUInt8()},r.prototype.readInt24=function(){var t=this.readUInt24();return 8388608&t?t-16777216:t},r.prototype.readUInt32=function(){return(this.readUInt8()<<24)+(this.readUInt8()<<16)+(this.readUInt8()<<8)+this.readUInt8()},r.prototype.readBytes=function(t){var e=this.buffer.slice(this.pos,this.pos+t);return this.pos+=t,e},r.prototype.readString=function(t){var e=this.readBytes(t);return String.fromCharCode.apply(null,e)},r.prototype.readVarInt=function(){for(var t=0;!this.eof();){var e=this.readUInt8();if(!(128&e))return t+e;t+=127&e,t<<=7}return t},r.prototype.readChunk=function(){var t=this.readString(4),e=this.readUInt32();return{id:t,length:e,data:this.readBytes(e)}},t.exports=function(t){var n=new r(t),i=n.readChunk();if("MThd"!=i.id)throw"Bad MIDI file. Expected 'MHdr', got: '"+i.id+"'";for(var a=function(t){var e=new r(t),n={format:e.readUInt16(),numTracks:e.readUInt16()},i=e.readUInt16();return 32768&i?(n.framesPerSecond=256-(i>>8),n.ticksPerFrame=255&i):n.ticksPerBeat=i,n}(i.data),o=[],s=0;!n.eof()&&s{function e(t,e,i){var a,o=new n,s=e.length,c=null;for(a=0;a>7&127;t.writeUInt8(p),t.writeUInt8(l);break;default:throw"Unrecognized event type: "+i}return c}function n(){this.buffer=[]}n.prototype.writeUInt8=function(t){this.buffer.push(255&t)},n.prototype.writeInt8=n.prototype.writeUInt8,n.prototype.writeUInt16=function(t){var e=t>>8&255,r=255&t;this.writeUInt8(e),this.writeUInt8(r)},n.prototype.writeInt16=n.prototype.writeUInt16,n.prototype.writeUInt24=function(t){var e=t>>16&255,r=t>>8&255,n=255&t;this.writeUInt8(e),this.writeUInt8(r),this.writeUInt8(n)},n.prototype.writeInt24=n.prototype.writeUInt24,n.prototype.writeUInt32=function(t){var e=t>>24&255,r=t>>16&255,n=t>>8&255,i=255&t;this.writeUInt8(e),this.writeUInt8(r),this.writeUInt8(n),this.writeUInt8(i)},n.prototype.writeInt32=n.prototype.writeUInt32,n.prototype.writeBytes=function(t){this.buffer=this.buffer.concat(Array.prototype.slice.call(t,0))},n.prototype.writeString=function(t){var e,r=t.length,n=[];for(e=0;e>=7;e;){var n=127&e|128;r.push(n),e>>=7}this.writeBytes(r.reverse())}},n.prototype.writeChunk=function(t,e){this.writeString(t),this.writeUInt32(e.length),this.writeBytes(e)},t.exports=function(t,r){if("object"!=typeof t)throw"Invalid MIDI data";r=r||{};var i,a=t.header||{},o=t.tracks||[],s=o.length,c=new n;for(function(t,e,r){var i=null==e.format?1:e.format,a=128;e.timeDivision?a=e.timeDivision:e.ticksPerFrame&&e.framesPerSecond?a=-(255&e.framesPerSecond)<<8|255&e.ticksPerFrame:e.ticksPerBeat&&(a=32767&e.ticksPerBeat);var o=new n;o.writeUInt16(i),o.writeUInt16(r),o.writeUInt16(a),t.writeChunk("MThd",o.buffer)}(c,a,s),i=0;i{"use strict";function r(t,e,r){void 0===r&&(r="ticks");var n=0,i=t.length,a=i;if(i>0&&t[i-1][r]<=e)return i-1;for(;ne)return o;s[r]>e?a=o:s[r]{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.ControlChange=e.controlChangeIds=e.controlChangeNames=void 0,e.controlChangeNames={1:"modulationWheel",2:"breath",4:"footController",5:"portamentoTime",7:"volume",8:"balance",10:"pan",64:"sustain",65:"portamentoTime",66:"sostenuto",67:"softPedal",68:"legatoFootswitch",84:"portamentoControl"},e.controlChangeIds=Object.keys(e.controlChangeNames).reduce((function(t,r){return t[e.controlChangeNames[r]]=r,t}),{});var r=new WeakMap,n=new WeakMap,i=function(){function t(t,e){r.set(this,e),n.set(this,t.controllerType),this.ticks=t.absoluteTime,this.value=t.value}return Object.defineProperty(t.prototype,"number",{get:function(){return n.get(this)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"name",{get:function(){return e.controlChangeNames[this.number]?e.controlChangeNames[this.number]:null},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"time",{get:function(){return r.get(this).ticksToSeconds(this.ticks)},set:function(t){var e=r.get(this);this.ticks=e.secondsToTicks(t)},enumerable:!1,configurable:!0}),t.prototype.toJSON=function(){return{number:this.number,ticks:this.ticks,time:this.time,value:this.value}},t}();e.ControlChange=i},906:(t,e,r)=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.createControlChanges=void 0;var n=r(543);e.createControlChanges=function(){return new Proxy({},{get:function(t,e){return t[e]?t[e]:n.controlChangeIds.hasOwnProperty(e)?t[n.controlChangeIds[e]]:void 0},set:function(t,e,r){return n.controlChangeIds.hasOwnProperty(e)?t[n.controlChangeIds[e]]=r:t[e]=r,!0}})}},54:function(t,e,r){"use strict";var n=this&&this.__spreadArray||function(t,e,r){if(r||2===arguments.length)for(var n,i=0,a=e.length;i{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.Header=e.keySignatureKeys=void 0;var n=r(805),i=new WeakMap;e.keySignatureKeys=["Cb","Gb","Db","Ab","Eb","Bb","F","C","G","D","A","E","B","F#","C#"];var a=function(){function t(t){var r=this;if(this.tempos=[],this.timeSignatures=[],this.keySignatures=[],this.meta=[],this.name="",i.set(this,480),t){i.set(this,t.header.ticksPerBeat),t.tracks.forEach((function(t){t.forEach((function(t){t.meta&&("timeSignature"===t.type?r.timeSignatures.push({ticks:t.absoluteTime,timeSignature:[t.numerator,t.denominator]}):"setTempo"===t.type?r.tempos.push({bpm:6e7/t.microsecondsPerBeat,ticks:t.absoluteTime}):"keySignature"===t.type&&r.keySignatures.push({key:e.keySignatureKeys[t.key+7],scale:0===t.scale?"major":"minor",ticks:t.absoluteTime}))}))}));var n=0;t.tracks[0].forEach((function(t){n+=t.deltaTime,t.meta&&("trackName"===t.type?r.name=t.text:"text"!==t.type&&"cuePoint"!==t.type&&"marker"!==t.type&&"lyrics"!==t.type||r.meta.push({text:t.text,ticks:n,type:t.type}))})),this.update()}}return t.prototype.update=function(){var t=this,e=0,r=0;this.tempos.sort((function(t,e){return t.ticks-e.ticks})),this.tempos.forEach((function(n,i){var a=i>0?t.tempos[i-1].bpm:t.tempos[0].bpm,o=n.ticks/t.ppq-r,s=60/a*o;n.time=s+e,e=n.time,r+=o})),this.timeSignatures.sort((function(t,e){return t.ticks-e.ticks})),this.timeSignatures.forEach((function(e,r){var n=r>0?t.timeSignatures[r-1]:t.timeSignatures[0],i=(e.ticks-n.ticks)/t.ppq/n.timeSignature[0]/(n.timeSignature[1]/4);n.measures=n.measures||0,e.measures=i+n.measures}))},t.prototype.ticksToSeconds=function(t){var e=(0,n.search)(this.tempos,t);if(-1!==e){var r=this.tempos[e],i=r.time,a=(t-r.ticks)/this.ppq;return i+60/r.bpm*a}return t/this.ppq*.5},t.prototype.ticksToMeasures=function(t){var e=(0,n.search)(this.timeSignatures,t);if(-1!==e){var r=this.timeSignatures[e],i=(t-r.ticks)/this.ppq;return r.measures+i/(r.timeSignature[0]/r.timeSignature[1])/4}return t/this.ppq/4},Object.defineProperty(t.prototype,"ppq",{get:function(){return i.get(this)},enumerable:!1,configurable:!0}),t.prototype.secondsToTicks=function(t){var e=(0,n.search)(this.tempos,t,"time");if(-1!==e){var r=this.tempos[e],i=(t-r.time)/(60/r.bpm);return Math.round(r.ticks+i*this.ppq)}var a=t/.5;return Math.round(a*this.ppq)},t.prototype.toJSON=function(){return{keySignatures:this.keySignatures,meta:this.meta,name:this.name,ppq:this.ppq,tempos:this.tempos.map((function(t){return{bpm:t.bpm,ticks:t.ticks}})),timeSignatures:this.timeSignatures}},t.prototype.fromJSON=function(t){this.name=t.name,this.tempos=t.tempos.map((function(t){return Object.assign({},t)})),this.timeSignatures=t.timeSignatures.map((function(t){return Object.assign({},t)})),this.keySignatures=t.keySignatures.map((function(t){return Object.assign({},t)})),this.meta=t.meta.map((function(t){return Object.assign({},t)})),i.set(this,t.ppq),this.update()},t.prototype.setTempo=function(t){this.tempos=[{bpm:t,ticks:0}],this.update()},t}();e.Header=a},362:(t,e,r)=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.Instrument=void 0;var n=r(438),i=new WeakMap,a=function(){function t(t,e){if(this.number=0,i.set(this,e),this.number=0,t){var r=t.find((function(t){return"programChange"===t.type}));r&&(this.number=r.programNumber)}}return Object.defineProperty(t.prototype,"name",{get:function(){return this.percussion?n.DrumKitByPatchID[this.number]:n.instrumentByPatchID[this.number]},set:function(t){var e=n.instrumentByPatchID.indexOf(t);-1!==e&&(this.number=e)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"family",{get:function(){return this.percussion?"drums":n.InstrumentFamilyByID[Math.floor(this.number/8)]},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"percussion",{get:function(){return 9===i.get(this).channel},enumerable:!1,configurable:!0}),t.prototype.toJSON=function(){return{family:this.family,number:this.number,name:this.name}},t.prototype.fromJSON=function(t){this.number=t.number},t}();e.Instrument=a},438:(t,e)=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.DrumKitByPatchID=e.InstrumentFamilyByID=e.instrumentByPatchID=void 0,e.instrumentByPatchID=["acoustic grand piano","bright acoustic piano","electric grand piano","honky-tonk piano","electric piano 1","electric piano 2","harpsichord","clavi","celesta","glockenspiel","music box","vibraphone","marimba","xylophone","tubular bells","dulcimer","drawbar organ","percussive organ","rock organ","church organ","reed organ","accordion","harmonica","tango accordion","acoustic guitar (nylon)","acoustic guitar (steel)","electric guitar (jazz)","electric guitar (clean)","electric guitar (muted)","overdriven guitar","distortion guitar","guitar harmonics","acoustic bass","electric bass (finger)","electric bass (pick)","fretless bass","slap bass 1","slap bass 2","synth bass 1","synth bass 2","violin","viola","cello","contrabass","tremolo strings","pizzicato strings","orchestral harp","timpani","string ensemble 1","string ensemble 2","synthstrings 1","synthstrings 2","choir aahs","voice oohs","synth voice","orchestra hit","trumpet","trombone","tuba","muted trumpet","french horn","brass section","synthbrass 1","synthbrass 2","soprano sax","alto sax","tenor sax","baritone sax","oboe","english horn","bassoon","clarinet","piccolo","flute","recorder","pan flute","blown bottle","shakuhachi","whistle","ocarina","lead 1 (square)","lead 2 (sawtooth)","lead 3 (calliope)","lead 4 (chiff)","lead 5 (charang)","lead 6 (voice)","lead 7 (fifths)","lead 8 (bass + lead)","pad 1 (new age)","pad 2 (warm)","pad 3 (polysynth)","pad 4 (choir)","pad 5 (bowed)","pad 6 (metallic)","pad 7 (halo)","pad 8 (sweep)","fx 1 (rain)","fx 2 (soundtrack)","fx 3 (crystal)","fx 4 (atmosphere)","fx 5 (brightness)","fx 6 (goblins)","fx 7 (echoes)","fx 8 (sci-fi)","sitar","banjo","shamisen","koto","kalimba","bag pipe","fiddle","shanai","tinkle bell","agogo","steel drums","woodblock","taiko drum","melodic tom","synth drum","reverse cymbal","guitar fret noise","breath noise","seashore","bird tweet","telephone ring","helicopter","applause","gunshot"],e.InstrumentFamilyByID=["piano","chromatic percussion","organ","guitar","bass","strings","ensemble","brass","reed","pipe","synth lead","synth pad","synth effects","world","percussive","sound effects"],e.DrumKitByPatchID={0:"standard kit",8:"room kit",16:"power kit",24:"electronic kit",25:"tr-808 kit",32:"jazz kit",40:"brush kit",48:"orchestra kit",56:"sound fx kit"}},233:function(t,e,r){"use strict";var n=this&&this.__awaiter||function(t,e,r,n){return new(r||(r=Promise))((function(i,a){function o(t){try{c(n.next(t))}catch(t){a(t)}}function s(t){try{c(n.throw(t))}catch(t){a(t)}}function c(t){var e;t.done?i(t.value):(e=t.value,e instanceof r?e:new r((function(t){t(e)}))).then(o,s)}c((n=n.apply(t,e||[])).next())}))},i=this&&this.__generator||function(t,e){var r,n,i,a,o={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return a={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(a[Symbol.iterator]=function(){return this}),a;function s(a){return function(s){return function(a){if(r)throw new TypeError("Generator is already executing.");for(;o;)try{if(r=1,n&&(i=2&a[0]?n.return:a[0]?n.throw||((i=n.return)&&i.call(n),0):n.next)&&!(i=i.call(n,a[1])).done)return i;switch(n=0,i&&(a=[2&a[0],i.value]),a[0]){case 0:case 1:i=a;break;case 4:return o.label++,{value:a[1],done:!1};case 5:o.label++,n=a[1],a=[0];continue;case 7:a=o.ops.pop(),o.trys.pop();continue;default:if(!((i=(i=o.trys).length>0&&i[i.length-1])||6!==a[0]&&2!==a[0])){o=0;continue}if(3===a[0]&&(!i||a[1]>i[0]&&a[1]{"use strict";function r(t){return["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"][t%12]}Object.defineProperty(e,"__esModule",{value:!0}),e.Note=void 0;var n,i,a=(n=/^([a-g]{1}(?:b|#|x|bb)?)(-?[0-9]+)/i,i={cbb:-2,cb:-1,c:0,"c#":1,cx:2,dbb:0,db:1,d:2,"d#":3,dx:4,ebb:2,eb:3,e:4,"e#":5,ex:6,fbb:3,fb:4,f:5,"f#":6,fx:7,gbb:5,gb:6,g:7,"g#":8,gx:9,abb:7,ab:8,a:9,"a#":10,ax:11,bbb:9,bb:10,b:11,"b#":12,bx:13},function(t){var e=n.exec(t),r=e[1],a=e[2];return i[r.toLowerCase()]+12*(parseInt(a,10)+1)}),o=new WeakMap,s=function(){function t(t,e,r){o.set(this,r),this.midi=t.midi,this.velocity=t.velocity,this.noteOffVelocity=e.velocity,this.ticks=t.ticks,this.durationTicks=e.ticks-t.ticks}return Object.defineProperty(t.prototype,"name",{get:function(){return t=this.midi,e=Math.floor(t/12)-1,r(t)+e.toString();var t,e},set:function(t){this.midi=a(t)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"octave",{get:function(){return Math.floor(this.midi/12)-1},set:function(t){var e=t-this.octave;this.midi+=12*e},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"pitch",{get:function(){return r(this.midi)},set:function(t){this.midi=12*(this.octave+1)+["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"].indexOf(t)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"duration",{get:function(){var t=o.get(this);return t.ticksToSeconds(this.ticks+this.durationTicks)-t.ticksToSeconds(this.ticks)},set:function(t){var e=o.get(this).secondsToTicks(this.time+t);this.durationTicks=e-this.ticks},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"time",{get:function(){return o.get(this).ticksToSeconds(this.ticks)},set:function(t){var e=o.get(this);this.ticks=e.secondsToTicks(t)},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"bars",{get:function(){return o.get(this).ticksToMeasures(this.ticks)},enumerable:!1,configurable:!0}),t.prototype.toJSON=function(){return{duration:this.duration,durationTicks:this.durationTicks,midi:this.midi,name:this.name,ticks:this.ticks,time:this.time,velocity:this.velocity}},t}();e.Note=s},882:(t,e)=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.PitchBend=void 0;var r=new WeakMap,n=function(){function t(t,e){r.set(this,e),this.ticks=t.absoluteTime,this.value=t.value}return Object.defineProperty(t.prototype,"time",{get:function(){return r.get(this).ticksToSeconds(this.ticks)},set:function(t){var e=r.get(this);this.ticks=e.secondsToTicks(t)},enumerable:!1,configurable:!0}),t.prototype.toJSON=function(){return{ticks:this.ticks,time:this.time,value:this.value}},t}();e.PitchBend=n},334:(t,e,r)=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.Track=void 0;var n=r(805),i=r(543),a=r(906),o=r(882),s=r(362),c=r(518),u=new WeakMap,h=function(){function t(t,e){var r=this;if(this.name="",this.notes=[],this.controlChanges=(0,a.createControlChanges)(),this.pitchBends=[],u.set(this,e),t){var n=t.find((function(t){return"trackName"===t.type}));this.name=n?n.text:""}if(this.instrument=new s.Instrument(t,this),this.channel=0,t){for(var i=t.filter((function(t){return"noteOn"===t.type})),o=t.filter((function(t){return"noteOff"===t.type})),c=function(){var t=i.shift();h.channel=t.channel;var e=o.findIndex((function(e){return e.noteNumber===t.noteNumber&&e.absoluteTime>=t.absoluteTime}));if(-1!==e){var r=o.splice(e,1)[0];h.addNote({durationTicks:r.absoluteTime-t.absoluteTime,midi:t.noteNumber,noteOffVelocity:r.velocity/127,ticks:t.absoluteTime,velocity:t.velocity/127})}},h=this;i.length;)c();t.filter((function(t){return"controller"===t.type})).forEach((function(t){r.addCC({number:t.controllerType,ticks:t.absoluteTime,value:t.value/127})})),t.filter((function(t){return"pitchBend"===t.type})).forEach((function(t){r.addPitchBend({ticks:t.absoluteTime,value:t.value/Math.pow(2,13)})}));var f=t.find((function(t){return"endOfTrack"===t.type}));this.endOfTrackTicks=void 0!==f?f.absoluteTime:void 0}}return t.prototype.addNote=function(t){var e=u.get(this),r=new c.Note({midi:0,ticks:0,velocity:1},{ticks:0,velocity:0},e);return Object.assign(r,t),(0,n.insert)(this.notes,r,"ticks"),this},t.prototype.addCC=function(t){var e=u.get(this),r=new i.ControlChange({controllerType:t.number},e);return delete t.number,Object.assign(r,t),Array.isArray(this.controlChanges[r.number])||(this.controlChanges[r.number]=[]),(0,n.insert)(this.controlChanges[r.number],r,"ticks"),this},t.prototype.addPitchBend=function(t){var e=u.get(this),r=new o.PitchBend({},e);return Object.assign(r,t),(0,n.insert)(this.pitchBends,r,"ticks"),this},Object.defineProperty(t.prototype,"duration",{get:function(){if(!this.notes.length)return 0;for(var t=this.notes[this.notes.length-1].time+this.notes[this.notes.length-1].duration,e=0;e{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r(233)})()})); + //# sourceMappingURL=Midi.js.map + /* eslint-enable */ + + return module.exports.Midi; + })(); + + class MidiFileManager { + cache = new Map(); + async fetchMidiUrl(url, force = false) { + if (this.cache.has(url) && !force) { + return this.cache.get(url); + } + + if (!(await Scratch.canFetch(url))) { + throw new Error(`Permission to fetch ${url} denied`); + } + const res = await Scratch.fetch(url, { mode: "cors" }); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText} fetch ${url} failed`); + } + + // Parse MIDI file + const midi = new Midi(await res.arrayBuffer()); + // const parsed = this.parseMidiData(midi, "ANY"); + this.cache.set(url, midi); + return midi; + } + /** + * + * @param {any} midi midi file from cache + * @param {EventType | "ANY"} filter + * @returns + */ + parseMidiData(midi, filter = "ANY") { + // Store note information + /** @type {MidiEvent[]} */ + const events = []; + if (filter === "ANY" || filter === "meta") { + events.push(...this._parseHeader(midi.header)); + } + events.push( + ...midi.tracks.flatMap((track) => this._parseTrack(track, filter)) + ); + + const sortOrder = ["meta", "programChange", "note"]; + return events.sort((a, b) => { + // sort by timestamp + let delta = a.time - b.time; + delta ||= (a.channel ?? 0) - (b.channel ?? 0); + delta ||= + sortOrder.indexOf(a.type) + 1 - (sortOrder.indexOf(b.type) + 1); + return delta; + }); + } + /** + * + * @param {*} header + * @returns {MidiEvent[]} + */ + _parseHeader(header) { + const { + keySignatures = [], + meta = [], + // ppq, + tempos = [], + timeSignatures = [], + } = header; + /** + * + * @param {any[]} list + * @param {(evt: any) => Partial} transform + * @returns {MidiEvent[]} + */ + const toEventList = (list, transform) => { + return list.map((evt) => ({ + type: "meta", + time: header.ticksToSeconds(evt.ticks), + ...transform(evt), + })); + }; + return [ + ...toEventList(tempos, ({ bpm }) => ({ tempo: bpm })), + ...toEventList(timeSignatures, ({ timeSignature }) => ({ + timeSignature: `${timeSignature[0]}/${timeSignature[1]}`, + })), + ...toEventList(keySignatures, ({ key, scale }) => ({ + keySignature: `${key}${scale}`, + })), + // FUTURE type could be "lyrics", "cuePoint", "marker" or "text" + ...toEventList(meta, ({ text, type: _ }) => ({ text })), + ]; + } + /** + * + * @param {*} track + * @param {EventType | "ANY"} filter + */ + _parseTrack(track, filter) { + const channel = track.channel + 1; + const { name: trackName, instrument } = track; + /** @type {MidiEvent[]} */ + const events = []; + if (filter === "ANY" || filter.includes("meta")) { + if (trackName) { + events.push({ + type: "meta", + channel, + trackName, + time: 0, + }); + } + if (instrument?.number !== undefined) { + events.push({ + type: "programChange", + channel, + value: instrument.number, + ...(instrument.name && { instrument: instrument.name }), + time: 0, + }); + } + } + if (filter === "ANY" || filter === "note") { + events.push( + ...track.notes.map((note) => ({ + type: "note", + channel, + pitch: note.name, + time: roundToMillisecond(note.time), + dur: roundToMillisecond(note.duration), + velocity: Math.round(note.velocity * 127), + })) + ); + } + if (filter === "ANY" || filter === "cc") { + const ccEvents = Object.values(track.controlChanges).flatMap( + (arr) => arr + ); + // @ts-ignore + events.push( + ...ccEvents.map((evt) => ({ + type: /** @type {EventType} */ ("cc"), + channel, + cc: evt.number, + time: roundToMillisecond(evt.time), + value: Math.round(evt.value * 127), + })) + ); + } + if (filter === "ANY" || filter === "pitchBend") { + events.push( + ...track.pitchBends.map((evt) => ({ + type: "pitchBend", + channel, + time: roundToMillisecond(evt.time), + // REVIEW - saving highResParam as 0-16384 instead of -1 -> 1 + value: Math.round((evt.value + 1) * 8192), + })) + ); + } + return events; + } + } + const fileManager = new MidiFileManager(); + + function roundToMillisecond(value) { + return Math.round(value * 1000) / 1000; + } + + //#endregion + + //#region utils + + /** + * This section includes logic to map raw data coming from the 'midimessage' + * event into a friendly object representation of the event + * + * + * // definition for the parsed midi event + * @typedef {'note' | 'noteOff' | 'cc' | 'polyTouch' | 'programChange' | 'pitchBend' | 'channelPressure' | 'songPosition' | 'songSelect' | 'clock' | 'start' | 'continue' | 'stop' | 'activeSensing' | 'reset' | 'meta'} EventType + * + * @typedef {'timeSignature' | 'tempo' | 'keySignature' | 'text' | 'trackName' | 'instrument'} MetaKey + * + * + * @typedef {object} MidiEvent + * @property {EventType | 'rest'} type type of midi command (note on, program change, tc). Default is 'note' + * @property {number} [value1] (0-127) raw data1 byte value + * @property {number} [value2] (0-127) raw data2 byte value + * @property {number} [channel] (1-16) channel of event. default is 1 + * @property {number} [device] (1-N) index of midi input/output device + * @property {number} [time] time of event in seconds + * @property {number} [pitch] note pitch (0-127). C4=60 + * @property {number} [velocity] note velocity (0-127). If this is 0 then treated as a note off event + * @property {number} [cc] continuous controller number (0-127) + * @property {number} [value] cc / pitchBend/programChange value (0-127 except for songPosition / pitchbend) + * @property {number} [pos] time in beats - gets converted to time using current tempo + * @property {number} [dur] (note type only) duration in seconds- send corresponding note off event automatically + * @property {number} [beats] gets converted to dur using current tempo + * + * // midi file meta fields + * @property {string} [keySignature] midi file only + * @property {string} [text] midi file lyric event + * @property {string} [trackName] midi file name of track + * @property {string} [timeSignature] midi file 3/8 4/4 + * @property {number} [tempo] midi file tempo bpm + * @property {string} [instrument] midi file friendly instrument name + * + * @property {string} [_str] cached string representation of this event + * + * + * @typedef {object} FormatOptions + * @property {number} [tempo] override tempo used in converting beats/pos. default is stage tempo + * @property {'sharps' |'flats' | 'num'} [pitchFormat] show notes as flats instead of sharps, or number value + * @property {boolean} [noMinify] include all data even if defaults + * @property {'omit' | 'timestamp' | 'absolute'} [timestampFormat] for future use + * @property {number} [startTimestamp] for future use + * @property {boolean} [fixedWidth] include padding to make values line up + * @property {boolean} [useHex] use hex for number values instead of base 10 + * @property {boolean} [useFractions] output as fractions if possible + * @property {number} [defaultOctave] default octave if not otherwise specfied. default 4 + * + */ + + /** + * MIDI commands with code, name, and parameters + * From: https://ccrma.stanford.edu/~craig/articles/linuxmidi/misc/essenmidi.html + * https://www.midi.org/specifications/item/table-1-summary-of-midi-message + * + * adapted from https://github.com/fheyen/musicvis-lib/blob/905edbdc8280e8ca76a329ffc83a160f3cda674a/src/fileFormats/Midi.js#L41 + * + * each key (the "EventType" relates to a raw midi "command". The "shorthand" could + * be used to format midi events to string (future). param1 and param2 determine what property of the object the value1 + value2 bytes mean (i.e. note gets pitch + velocity, cc gets cc# and value) + */ + const SHARPS = "C C# D D# E F F# G G# A A# B".split(" "); + const FLATS = "C Db D Eb E F Gb G Ab A Bb B".split(" "); + function midiPitchToNoteName( + midi, + { pitchFormat = "sharps", fixedWidth = false } = {} + ) { + if (!isFinite(midi) || pitchFormat === "num") + return Scratch.Cast.toString(midi) || ""; + let chroma = (pitchFormat === "flats" ? FLATS : SHARPS)[midi % 12]; + if (fixedWidth) chroma = chroma.padEnd(2, "_"); + const octave = Math.floor(midi / 12) - 1; + return `${chroma}${octave}`; + } + /** + * convert a note string name (or raw midi number) to midi number value + * Examples of valid inputs: C4 C_4 C#7 Db6 F# F♯♯ B♭0 60 32 + * Returns null if not a valid pitch string or midi note number + * @param {string} note + * @param {number} [defaultOctave] + * @returns {number | null} + */ + function noteNameToMidiPitch(note, defaultOctave = 4) { + const parts = + /(?[A-G])(?[b♭]+)?(?[#♯]+)?_?(?-?\d+)?/i.exec( + note || "" + ); + if (!parts?.groups) { + const numVal = + typeof note === "string" ? parseInt(note.trim(), 10) : +note; + return numVal >= 0 && numVal <= 127 ? numVal : null; + } + const { pitch, octave, flat, sharp } = parts.groups; + let chroma = + SHARPS.indexOf(pitch.toUpperCase()) - + (flat?.length || 0) + + (sharp?.length || 0); + const height = octave ? parseInt(octave, 10) : defaultOctave; + return chroma + (height + 1) * 12; + } + function parseNumValue(text, opts) { + if (typeof text === "number") return text; + if (!text) return undefined; + text = text.trim(); + // parse float value + if (text.includes(".")) return parseFloat(text); + const useHex = opts?.useHex ?? /[a-f]/i.test(text); + const radix = useHex ? 16 : 10; + const val = parseInt(text, radix); + return isNaN(val) ? undefined : val; + } + function formatHex(value, pad = 2) { + return Math.round(value).toString(16).padStart(pad, "0"); + } + + /** + * IMPORTANT! This mapping is key in translating raw midi messages to/from their object/string representation. + * @typedef {object} EventSpec Definition of midi event type and how to convert to/from raw midi + * @property {string} shorthand Short name used in string representation + * @property {number} command Actual midi command bytes (1st byte along with channel) + * @property {string} description English description of type TODO - translate if ever exposed in UI + * @property {'pitch' | 'cc' | 'value'} [param1] - corresponding key for 2nd byte of raw message + * @property {'velocity' | 'value'} [param2] - corresponding key for 3rd byte of raw message + * @property {'value'} [highResParam] - corresponding key for "high res" param that goes from 0-16384 instead of 127 + * @property {[data1: number, data2: number]} [defaults] - default values if not otherwise specified + */ + + /** + * @type {Record} + */ + const eventMapping = { + note: { + shorthand: "note", + command: 144, + description: "Note-on", + param1: "pitch", + param2: "velocity", + defaults: [60, 96], + }, + noteOff: { + shorthand: "off", + command: 128, + description: "Note-off", + param1: "pitch", + param2: "velocity", + defaults: [60, 0], + }, + cc: { + shorthand: "cc", + command: 176, + description: "Continuous controller", + param1: "cc", + param2: "value", + defaults: [0, 0], + }, + polyTouch: { + shorthand: "touch", + command: 160, + description: "Aftertouch", + param1: "pitch", + param2: "value", + defaults: [60, 64], + }, + programChange: { + shorthand: "program", + command: 192, + description: "Patch change", + param1: "value", + }, + pitchBend: { + shorthand: "bend", + command: 224, + description: "Pitch bend", + highResParam: "value", + }, + channelPressure: { + shorthand: "pressure", + command: 208, + description: "Channel Pressure", + param1: "value", + }, + songPosition: { + shorthand: "songpos", + command: 242, + description: "Song Position Pointer (Sys Common)", + highResParam: "value", + }, + songSelect: { + shorthand: "songsel", + command: 243, + description: "Song Select (Sys Common)", + param1: "value", + }, + clock: { + shorthand: "clock", + command: 248, + description: "Timing Clock (Sys Realtime)", + }, + start: { + shorthand: "start", + command: 250, + description: "Start (Sys Realtime)", + }, + continue: { + shorthand: "continue", + command: 251, + description: "Continue (Sys Realtime)", + }, + stop: { + shorthand: "stop", + command: 252, + description: "Stop (Sys Realtime)", + }, + activeSensing: { + shorthand: "ping", + command: 254, + description: "Active Sensing (Sys Realtime)", + }, + reset: { + shorthand: "reset", + command: 255, + description: "System Reset (Sys Realtime)", + }, + meta: { + shorthand: "meta", + command: 0, + description: "MIDI File Meta Message", + }, + }; + /** @type {Map} */ + // @ts-ignore + const commandLookup = new Map( + Object.entries(eventMapping) + // ignore midi file only entries (i.e. "meta") + .filter(([key, { command }]) => !!command) + .map(([key, { command }]) => [command, key]) + ); + /** @type {Map} */ + const shorthandLookup = Object.fromEntries([ + ...Object.entries(eventMapping).map(([key, { shorthand }]) => [ + shorthand, + key, + ]), + ...Object.keys(eventMapping).map((key) => [key.toLowerCase(), key]), + ]); + const shorthands = Object.fromEntries( + Object.entries(eventMapping).map(([key, { shorthand }]) => [key, shorthand]) + ); + + /** aliases for MidiEvent keys + * the allows for more permissive translation of input strings, i.e. ch10 === channel=10 + * @type {Record} */ + const paramLookup = { + type: "type", + note: "pitch", + pitch: "pitch", + ch: "channel", + channel: "channel", + dev: "device", + device: "device", + t: "time", + time: "time", + "@": "time", + dur: "dur", + duration: "dur", + pos: "pos", + beats: "beats", + value: "value", + }; + /** @type {ReadonlyArray} */ + const metaKeys = [ + "tempo", + "timeSignature", + "keySignature", + "trackName", + "text", + "instrument", + ]; + + // These are how different midi event keys are split. ch and dev are special cases that don't include =, since they're common. Other params use = to allow arbitrary additional properties if needed + const PREFIX_CHANNEL = "ch"; + const PREFIX_DEVICE = "dev"; + const PREFIX_WHEN = "t="; + const PREFIX_POS = "pos="; + const PREFIX_DURATION = "dur="; + /** Used when creating lists - treat ~ as empty no value */ + const REST_LITERAL = "~"; + + /** + * @param {string} value + * @returns {EventType | undefined} + */ + function normalizeType(value) { + if (typeof value === "number") { + return commandLookup.get(value); + } + if (typeof value !== "string") return undefined; + return eventMapping[value] !== undefined + ? value + : shorthandLookup[value.toLowerCase()]; + } + + function formatNumValue(value, opts) { + const str = opts?.useHex + ? formatHex(value) + : value.toFixed().padStart(opts?.fixedWidth ? 2 : 3, "0"); + return str; + } + function formatDefault(type, value, value2, opts) { + return `${type} ${formatNumValue(value, opts)}${value2 != undefined ? " " + formatNumValue(value2, opts) : ""}`; + } + function formatNoteType(type, note, value, opts = {}) { + return `${type ? type + " " : ""}${midiPitchToNoteName(note, opts)} ${formatNumValue(value, opts)}`; + } + /** + * This is a mapping of midi events and their string representation + * @type {Record string>} + */ + const formatters = { + note( + { value1: note, value2: velocity = eventMapping.note.defaults[1] }, + opts = {} + ) { + return formatNoteType( + // opts?.noMinify ? shorthands.note : undefined, + shorthands.note, + note, + velocity, + opts + ); + }, + noteOff({ value1: note }, opts = {}) { + return formatNoteType( + // opts?.noMinify ? shorthands.noteOff : undefined, + shorthands.noteOff, + note, + 0, + opts + ); + }, + polyTouch( + { value1: note, value2: value = eventMapping.polyTouch.defaults[1] }, + opts = {} + ) { + return formatNoteType(shorthands.polyTouch, note, value, opts); + }, + cc({ value1: cc, value2: value }, opts) { + return formatDefault(shorthands.cc, cc, value, opts); + }, + programChange({ value1: program }, opts) { + return formatDefault(shorthands.programChange, program, undefined, opts); + }, + channelPressure: ({ value1: value }, opts) => + formatDefault(shorthands.channelPressure, value, undefined, opts), + pitchBend({ value1, value2 }, opts) { + if (value1 == undefined) { + [value1, value2] = [0, 64]; + } + return `${shorthands.pitchBend} ${formatHighResValue(value1, value2, opts)}`; + }, + songPosition({ value1 = 0, value2 }, opts) { + return `${shorthands.songPosition} ${formatHighResValue(value1, value2, opts)}`; + }, + songSelect: ({ value1 }, opts) => + formatDefault(shorthands.songSelect, value1, undefined, opts), + // tuneRequest: () => shorthands.tuneRequest, + continue: () => shorthands.continue, + activeSensing: () => shorthands.activeSensing, + clock: () => shorthands.clock, + start: () => shorthands.start, + stop: () => shorthands.stop, + reset: () => shorthands.reset, + meta(event, opts) { + const keyValues = metaKeys + .map((key) => key in event && kvHelper.formatKeyValue(key, event[key])) + .filter(Boolean) + .join(" "); + return `${shorthands.meta} ${keyValues}`; + }, + }; + + /** + * convert midi event to string. + * The opts includes more formatting options than available through the current scratch blocks available. + * @param {MidiEvent} event + * @param {FormatOptions} opts + * @returns + */ + function midiToString(event, opts = {}) { + if (!event) return ""; + if (typeof event === "string") event = stringToMidi(event, opts); + const { noMinify } = opts; + const spec = eventMapping[event.type] ?? {}; + // ensure these are set properly + const { + value1 = event[spec.param1] ?? event[spec.highResParam], + value2 = event[spec.param2], + } = event; + const formatter = formatters[event.type]; + if (!formatter) { + console.debug(`unknown event type ${event.type}`); + return ""; + } + let msg = formatter({ ...event, value1, value2 }, opts); + if (event.channel != undefined || noMinify) { + msg += formatChannel(event.channel, opts); + } + if (event.device != undefined || noMinify) { + msg += formatDevice(event.device, opts); + } + // HACK just for midi file support, ideally this would be generic + if (event.instrument) { + msg += ` ${kvHelper.formatKeyValue("instrument", event.instrument)}`; + } + if (event.time != undefined) { + msg += formatTime(event, opts); + } + if (event.pos) { + msg += formatPosition(event.pos, opts); + } + if (event.dur) { + msg += formatDuration(event.dur, opts); + } + return msg; + } + + /** + * convert a string representation of a note to a midievent + * FUTURE - support unicode note durations? https://en.wikipedia.org/wiki/Musical_Symbols_(Unicode_block) + * @param {string} text + * @param {FormatOptions} opts + * @returns {MidiEvent} + */ + function stringToMidi(text, opts = {}) { + if (typeof text !== "string") text = Scratch.Cast.toString(text); + if (text === "" || text === "0") return null; + + /** @type {null | {[K in keyof MidiEvent | 'beats' | 'keyvals']?: string}} */ + let data = null; + + // handle JSON input + if (text.startsWith("{")) { + try { + data = JSON.parse(text); + } catch (_err) {} + } else { + const fullRe = + /^\s*(?[a-zA-Z]{2,}|~)?\s*((?[A-G][#b♯♭_]*-?\d?)|(?\b-?[0-9a-f]{1,5}\b))?\s*(?\b[0-9a-f]{1,3}\b)?\s*(?.*)\s*$/; + data = fullRe.exec(text)?.groups ?? null; + } + if (!data) return null; + + // add extra meta / keyvalue pairs + if (typeof data.keyvals === "string") { + // turn key=val other=32.43 @14.23 into {key: 'val', other: 32.43, time=14.23} + for (let { key, value } of kvHelper.tokenize(data.keyvals)) { + // match values that have shorthand notation + if (!key && value) { + [key, value] = /^(ch|dev|@)?(.*)$/.exec(value).slice(1); + } + if (!key) continue; + key = paramLookup[key] || key; + data[key] = data[key] ?? value; + } + } + + // midi value1 can be specified as pitch or as number + const value1 = data.pitch + ? noteNameToMidiPitch(data.pitch, opts.defaultOctave) + : parseNumValue(data.value1, opts); + + let value2 = parseNumValue(data.value2, opts); + + /** @type {MidiEvent} */ + const event = { + type: data.type === REST_LITERAL ? "rest" : normalizeType(data.type), + ...(value1 != undefined && { value1 }), + ...(value2 != undefined && { value2 }), + channel: parseNumValue(data.channel, opts), + device: parseNumValue(data.device, opts), + ...(data.time && { time: parseNumValue(data.time, opts) }), + ...(data.dur && { dur: parseFraction(data.dur) }), + ...(data.pos && { pos: parseFraction(data.pos) }), + ...(data.beats && { beats: parseFraction(data.beats) }), + }; + + if (event.beats && event.dur == undefined) { + // NOTE - looks up tempo in vm stage + event.dur = beatsToSeconds(event.beats, opts.tempo); + } + if (event.pos && event.time == undefined) { + // NOTE - looks up tempo in vm stage + event.time = beatsToSeconds(event.pos, opts.tempo); + } + + // default to note event if has pitch (off if velocity = 0) + if (!event.type && value1 >= 0) { + event.type = "note"; + } else if (!event.type) { + // no type set, invalid input + return null; + } + const spec = eventMapping[event.type]; + let parsedHighRes = undefined; + switch (event.type) { + case "note": + case "noteOff": + if (value1 == undefined) return null; + if (value2 === 0) event.type = "noteOff"; + if (value2 == undefined) { + event.value2 = + event.velocity ?? event.value ?? (event.type === "note" ? 96 : 0); + } + break; + case "polyTouch": + // these types have note pitch + if (event.value1 == undefined) return null; + if (value2 == undefined) { + event.value2 = event.value ?? event.velocity ?? 64; + } + break; + case "cc": + if (value2 == undefined) { + event.value2 = event.value; + value2 = event.value2; + } + if (value1 == undefined || value2 == undefined) return null; + break; + case "programChange": + if (data.instrument) event.instrument = data.instrument; + event.value1 ||= event.value ?? 0; + break; + case "channelPressure": + case "songSelect": + // default to 0 for these types + event.value1 ||= event.value ?? 0; + break; + case "songPosition": + case "pitchBend": + if (value1 == undefined) return null; + // these two types have a higher precision value + parsedHighRes = parseHighResValue(value1, value2); + Object.assign(event, { + [spec.highResParam ?? "value"]: parsedHighRes.value, + value1: parsedHighRes.value1, + value2: parsedHighRes.value2, + }); + break; + case "meta": + Object.assign( + event, + Object.fromEntries( + metaKeys + .filter((key) => key in data) + .map((key) => [ + key, + key === "tempo" ? parseFloat(data[key]) : data[key], + ]) + ) + ); + break; + case "clock": + case "start": + case "continue": + case "stop": + case "activeSensing": + case "reset": + break; + case "rest": + break; + // do nothing + } + // look at eventMap to give 'friendly' names to value1/value2 (ex. "pitch", "velocity") + if (spec?.param1 && event.value1 != undefined) { + event[spec.param1] = event.value1; + } + if (spec?.param2 && event.value2 != undefined) { + event[spec.param2] = event.value2; + } + + return event; + } + + /** + * Key Values helper + * generic helper to convert key=value pairs to/from javascript object + * any values that aren't in key=value format go into unkeyed array + */ + const kvHelper = { + _tokenizeRe: /(?\s*)((?\w+)=)?(?("[^"]*"|\S+)?)?/g, + *tokenize(str = "") { + const { unquote } = kvHelper; + let unkeyed = ""; + for (let match of String(str).matchAll(kvHelper._tokenizeRe)) { + const { key, value = "", ws } = match.groups; + if (!key) { + unkeyed += `${unkeyed ? ws : ""}${value}`; + continue; + } + if (unkeyed) { + yield { value: unquote(unkeyed) }; + unkeyed = ""; + } + yield { key, value: unquote(value) }; + } + if (unkeyed) yield { value: unquote(unkeyed) }; + }, + /** + * + * @param {*} str + * @returns {{ args: Record, _: string[]}} + */ + parseKeyValues(str = "") { + const out = { args: {}, _: [] }; + for (let { key, value } of kvHelper.tokenize(str)) { + if (key) { + out.args[key] = value; + } else { + out._.push(value); + } + } + return out; + }, + isQuoted(str) { + return str.length > 1 && str.startsWith('"') && str.endsWith('"'); + }, + quote(str, force = false) { + if (!/[\s="]/.test(str) && !force) return str; + if (kvHelper.isQuoted(str)) str = str.slice(1, -1); + return `"${str.replace(/"/g, "''")}"`; + }, + unquote(str) { + if (kvHelper.isQuoted(str)) { + return str.slice(1, -1).replace(/''/g, '"'); + } + return str; + }, + /** + * Convert an object into key/value pairs (values with special characters get quoted/escaped) + * @param {Record} args + * @param {any[]} [unkeyed] + * @param {boolean} [compact] remove falsy values + * @returns {string} + */ + formatKeyValues(args, unkeyed, compact = true) { + let entries = Object.entries(args); + if (Array.isArray(unkeyed)) entries.push(...unkeyed.map((v) => ["", v])); + if (compact) + entries = entries.filter(([_, value]) => value || value === 0); + return entries + .map(([key, val]) => kvHelper.formatKeyValue(key, val)) + .filter(Boolean) + .join(" "); + }, + /** + * + * @param {*} [key] + * @param {*} [value] + * @returns {string} + */ + formatKeyValue(key = "", value = "") { + const { quote } = kvHelper; + // REVIEW this makes sure output is compatible with parse by avoiding " or spaces etc + key = String(key).trim().replace(/[^\w]/, ""); + value = String(value).trim(); + if (key === "" && value === "") return ""; + // surround with quotes if necessary + return `${key ? `${quote(key)}=` : ""}${kvHelper.quote(value)}`; + }, + }; + + function formatChannel(channel = 0, opts) { + const str = opts?.useHex + ? formatHex(channel, 1) + : channel.toFixed().padStart(2, "0"); + return ` ${PREFIX_CHANNEL}${str}`; + } + function formatDevice(device = 0, opts) { + const str = opts?.useHex ? formatHex(device, 1) : device.toFixed(); + return ` ${PREFIX_DEVICE}${str}`; + } + function formatTime( + { time = undefined }, + { timestampFormat = "absolute", startTimestamp = 0 } + ) { + if (timestampFormat === "omit" || time == undefined) { + return ""; + } + if (timestampFormat === "absolute") { + return ` ${PREFIX_WHEN}${time.toFixed(3)}`; + } + const ONE_DAY = 1e3 * 60 * 60 * 24; + let val = (time - startTimestamp) % ONE_DAY; + return ` ${PREFIX_WHEN}${formatTimespan(val)}`; + } + + function formatPosition(value, opts) { + return ` ${PREFIX_POS}${formatSeconds(value, opts)}`; + } + + function formatDuration(value, opts) { + return ` ${PREFIX_DURATION}${formatSeconds(value, opts)}`; + } + + function formatSeconds(value, opts) { + // truncate to 1 millisecond accuracy + value = Math.round(value * 1000) / 1000; + return opts?.useFractions ? formatFraction(value) : `${value}`; + } + + function formatTimespan(seconds, hoursOptional = true) { + const ms = 1e3 * (seconds % 1); + let t = Math.floor(seconds); + const s = t % 60; + const m = Math.floor(t / 60); + const h = Math.floor(t / 3600); + const parts = hoursOptional && h === 0 ? [m, s] : [h, m, s]; + return `${parts.map((p) => p.toFixed(0).padStart(2, "0")).join(":")}.${ms.toFixed().padEnd(3, "0")}`; + } + // /** + // * + // * @param {string} text + // * @returns + // */ + // function parseTimespan(text) { + // if (!text || typeof text !== 'string') return +text || 0; + // // split into [hh]:mm:ss.000 parts + // const parts = text.split(":"); + // // somewhat unnecessarily clever method to sum [hh][mm][ss] + // // by incrementing the scale from [1, 60, 3600]. + // // goes right to left because hours and minutes are optional + // const [value] = parts.reduceRight( + // ([acc, scale], part) => { + // const val = (parseFloat(part) || 0) * scale; + // return [acc + val, scale * 60]; + // }, + // [0, 1] + // ); + // return value; + // } + function parseFraction(text) { + if (typeof text !== "string") { + return +text || 0; + } + const [top, bottom] = text.split("/"); + return parseFloat(top) / (parseFloat(bottom) || 1) || 0; + } + function formatFraction(value, threshold = 2e-3) { + for (let d of [4, 3, 10, 8, 1]) { + const numerator = value * d; + if (Math.abs(numerator % 1) <= threshold) { + return `${numerator}/${d}`; + } + } + // truncate to milliseconds (0.001) + return `${value}`.replace(/(\.\d{3})\d+$/, "$1"); + } + + function valueToMsbLsb(value) { + return { + value1: value & 127, + value2: value >> 7, + }; + } + function msbLsbToValue(lsb, msb = 0) { + return (msb << 7) + lsb; + } + function parseHighResValue(value1, value2) { + const value = value2 == undefined ? value1 : msbLsbToValue(value1, value2); + return { + value, + ...valueToMsbLsb(value), + }; + } + function formatHighResValue(value1, value2, opts = {}) { + if (opts?.useHex) { + return `${shorthands.pitchBend} ${formatHex(value1)} ${formatHex(value2)}`; + } + const value = msbLsbToValue(value1, value2); + let txt = value.toFixed(); + if (opts?.fixedWidth) { + txt = txt.padStart(5, " "); + } + return txt; + } + + /** + * parse raw input midi bytes + * @param {Uint8Array} data + * @returns {MidiEvent | null} + */ + function rawMessageToMidi(data) { + const [commandAndChannel, value1, value2] = data; + const channel = commandAndChannel % 16; + const command = commandAndChannel - channel; + const type = commandLookup.get(command); + if (!type) { + console.debug("unknown command type", command); + return null; + } + /** @type {MidiEvent} */ + const event = { + type, + channel: channel + 1, + ...(value1 != undefined && { value1 }), + ...(value2 != undefined && { value2 }), + }; + const spec = eventMapping[type]; + if (spec?.param1 && event.value1 != undefined) { + event[spec.param1] = event.value1; + } + if (spec?.param2 && event.value2 != undefined) { + event[spec.param2] = event.value2; + } + if (spec.highResParam) { + const { value } = parseHighResValue(value1, value2); + event[spec.highResParam] = value; + } + return event; + } + /** + * convert midi event into bytes expected by MidiOut.send + * @param {MidiEvent} event + * @returns {Uint8Array} + */ + function midiToRawMessage(event) { + let { type, value1, value2, channel = 1 } = event; + const spec = eventMapping[type]; + // return empty if not valid event + if (!spec || !spec.command) return new Uint8Array(); + const commandAndChannel = spec.command + Math.max(channel - 1, 0); + if (spec.param1 && value1 == undefined) { + value1 = (event[spec.param1] ?? spec.defaults?.[0]) || 0; + } + if (spec.param2 && value2 == undefined) { + value2 = event[spec.param2] ?? spec.defaults?.[1] ?? undefined; + } + if (spec.highResParam && event.value != undefined) { + const highRes = parseHighResValue(event.value); + [value1, value2] = [highRes.value1, highRes.value2]; + } + return new Uint8Array([ + commandAndChannel, + value1, + ...(value2 !== undefined ? [value2] : []), + ]); + } + /** + * read the "pos" / "dur" values of an event as relative time vs. a fixed wall-clock time + * @param {Pick} event + * @param {number} [offsetMs] time to return relative to. Default is now + */ + function getMidiOffsetTime(event, offsetMs = window.performance.now()) { + const { time = 0, dur } = event; + return { + start: offsetMs + time * 1000, + ...(dur > 0 && { + end: offsetMs + (time + dur) * 1000, + }), + }; + } + // function isPitchedEvent(type) { + // return ( + // !!type && + // (type === "note" || type === "noteOff" || type === "polyTouch") + // ); + // } + + // Make a full array of notes in full midi range from 0 (C-1) to 127 (G-9) + const MIDI_NOTES = "_" + .repeat(11) + .split("") + .flatMap((_, i) => SHARPS.map((c) => `${c}${i - 1}`)) + .slice(0, 128); + + /** + * Scratch menu items of @see {MidiEvent} properties + * @type {Record} + */ + const EVENT_PROPS = { + type: { key: "type", text: Scratch.translate("(type) Event Type") }, + pitch: { key: "pitch", text: Scratch.translate("(pitch) Note Pitch") }, + velocity: { + key: "velocity", + type: "note", + text: Scratch.translate("Velocity"), + }, + ccNumber: { + key: "cc", + text: Scratch.translate("(cc) Continuous Controller #"), + }, + ccValue: { + key: "value", + type: "cc", + text: Scratch.translate("(value) CC Value"), + }, + channel: { key: "channel", text: Scratch.translate("Channel") }, + device: { key: "device", text: Scratch.translate("Device") }, + pitchbend: { + key: "value", + type: "pitchBend", + text: Scratch.translate("(value) Pitch Bend"), + }, + aftertouch: { + key: "value", + type: "polyTouch", + text: Scratch.translate("(polyTouch) Aftertouch"), + }, + time: { key: "time", text: Scratch.translate("(time) Timestamp") }, + duration: { key: "dur", text: Scratch.translate("(dur) Duration") }, + instrument: { key: "instrument", text: Scratch.translate("Instrument") }, + }; + + //#endregion utils + + //#region wrapper + /** + * This is a singleton wrapper class around the WebMIDI API. It: + * 1) Handles requesting API permission and reporting Input/Output devices + * 2) Translate raw midi input events into friendlier @see {MidiEvent} objects + * 3) Sends out midi events, translating MidiEvents into the raw midi payload + * 4) Keeps track of "note on"/"note off" messages to support "panic" stopping midi output + * + * It extends the native EventTarget class to dispatch events + */ + class MidiBackend extends EventTarget { + constructor() { + super(); + this.status = "pending" /* Initial */; + /** @type {MIDIInput[]} */ + this.inputs = []; + /** @type {MIDIOutput[]} */ + this.outputs = []; + this._init = undefined; + /** @type {MidiEvent} */ + this._defaultOutputEvent = { + type: "note", + channel: 1, + device: 0, + pitch: 60, + dur: 0.5, + velocity: 196, + }; + // TIP! If you use arrow functions on class methods then 'this' is automatically bound correctly, even if using as event listener + this.refreshDevices = () => { + for (const input of this.access.inputs.values()) { + if (!this.inputs.some((d) => d.id === input.id)) { + input.addEventListener("midimessage", this._onInputEvent); + input.addEventListener("statechange", this._onDeviceStateChange); + this.inputs.push(input); + } + } + for (const output of this.access.outputs.values()) { + if (!this.outputs.some((d) => d.id === output.id)) { + this.outputs.push(output); + } + } + }; + /** + * + * @param {MIDIPortEventMap['statechange']} event + */ + this._onDeviceStateChange = (event) => { + const { port } = event; + if (!port) return; + const { type, id, name } = port; + const deviceList = type === "input" ? this.inputs : this.outputs; + const rawIndex = deviceList.findIndex((dev) => dev.id === id); + if (rawIndex === -1) { + this.refreshDevices(); + return; + } + this._emit("device:status", { + index: rawIndex + 1, + id, + name, + type, + state: port.state, + }); + }; + this._onInputEvent = (event) => { + const { target: device, data, timeStamp } = event; + if (!data) return; + const rawIndex = this.inputs.indexOf(device); + const midiEvent = rawMessageToMidi(data); + if (!midiEvent) { + console.warn("Unable to parse message", data); + this._emit("midi:unhandled", event); + return; + } else { + midiEvent.time = timeStamp / 1000; + if (rawIndex !== -1) { + midiEvent.device = rawIndex + 1; + } + midiEvent._str = midiToString(midiEvent, { noMinify: true }); + this._emit("midi", midiEvent); + } + }; + } + get defaultInput() { + return this.inputs.find((d) => d.state === "connected"); + } + get defaultOutput() { + return this.outputs.find((d) => d.state === "connected"); + } + initialize({ sysex = false, force = false, timeoutMS = 1e3 * 30 } = {}) { + if (this._init && !force) { + return this._init; + } + if (!navigator.requestMIDIAccess) { + return false; + } + return (this._init = (async () => { + this.status = "initializing" /* Initializing */; + this._emit("status", { status: this.status }); + try { + let timer; + const whenTimeout = new Promise((resolve, reject) => { + timer = setTimeout( + () => reject(new DOMException("Timeout waiting for midi access")), + timeoutMS + ); + }); + const midiAccess = await Promise.race([ + navigator.requestMIDIAccess({ sysex }), + whenTimeout, + ]); + clearTimeout(timer); + this.access = midiAccess; + midiAccess.addEventListener("statechange", this.refreshDevices); + this.refreshDevices(); + this.status = "connected" /* Connected */; + return true; + } catch (error) { + console.warn("Request failure", error); + if (sysex) { + return this.initialize({ sysex: false, force: true, timeoutMS }); + } + this.status = "error" /* Error */; + return false; + } finally { + this._emit("status", { status: this.status }); + } + })()); + } + /** + * + * @param {MidiEvent | string | number} event + * @param {MidiEvent} [defaults] + * @param {FormatOptions} [formatOpts] + */ + sendOutputEvent( + event, + defaults = this._defaultOutputEvent, + formatOpts = {} + ) { + /** @type {MidiEvent} */ + let data; + // midi pitch = note now for 1/2 beat + if (typeof event === "number") { + data = { + ...defaults, + pitch: event, + }; + } else if (typeof event === "string") { + data = { + ...defaults, + ...stringToMidi(event, formatOpts), + }; + } else if (typeof event === "object") { + data = event; + } else { + throw new TypeError( + "Invalid data to send to output - must be midievent object, string or number" + ); + } + + // ignore, just placebo rest event + if (data.type === "rest") { + return false; + } + + let device; + // passed in index starts at 1 + if (data.device != undefined && data.device > 0) { + device = this.outputs[data.device - 1]; + } + device ||= this.defaultOutput; + if (!device) { + // no output device so do nothing + return false; + } + // ensure 0 velocity is interpreted as noteOff + if (data.type === "note" && data.velocity === 0) { + data.type = "noteOff"; + } + + // TODO be able to specify offset time to calculate from? + const { start, end } = getMidiOffsetTime(data); + let raw = midiToRawMessage(data); + device.send(raw, start); + // also send off event + if (data.type === "note" && end) { + /** @type {MidiEvent} */ + const offEvent = { + ...data, + type: "noteOff", + value2: 0, + velocity: 0, + }; + raw = midiToRawMessage(offEvent); + device.send(raw, end); + } + + // Keep track of notes so stop events can be sent if necessary + this._trackActiveNotes(data, device); + } + + /** + * Stop all notes on all devices and clear pending + */ + panic() { + for (let [device, active] of this._activeNotes.entries()) { + if (device.state !== "connected") continue; + for (let key of active) { + const [channel, pitch] = key.split("_"); + const raw = midiToRawMessage({ + type: "noteOff", + channel: parseInt(channel, 10), + pitch: parseInt(pitch), + }); + // console.debug(`Stopping note ${pitch} on ${channel}`); + device.send(raw); + } + } + this._activeNotes.clear(); + } + /** + * Keep track of events sent to midi devices to allow panic stopping them if necessary + * @private + * @param {MidiEvent} event + * @param {MIDIOutput} device + */ + _trackActiveNotes(event, device) { + // NOTE - same default channel as midiToRawEvent, maybe default should be a magic value? + const { type, pitch, channel = 1 } = event; + if (!(type === "note" || type === "noteOff")) { + return; + } + const key = `${channel}_${pitch}`; + if (!this._activeNotes.has(device)) { + this._activeNotes.set(device, new Set()); + } + const list = this._activeNotes.get(device); + if (type === "note") { + list.add(key); + } else { + list.delete(key); + } + } + /** @type {Map>} */ + _activeNotes = new Map(); + _emit(name, data) { + const event = new CustomEvent(name, { detail: data }); + this.dispatchEvent(event); + } + // @ts-ignore + _whenEvent(event, { target = this, signal, timeoutMS = 1e3 * 60 } = {}) { + const events = Array.isArray(event) ? event : [event]; + let timer; + return new Promise((resolve, reject) => { + events.forEach((name) => + target.addEventListener(name, resolve, { once: true, signal }) + ); + timer = setTimeout(() => { + events.forEach((name) => target.removeEventListener(name, resolve)); + reject(new DOMException("Timeout", "TimeoutError")); + }, timeoutMS); + }).finally(() => clearTimeout(timer)); + } + on(type, callback, options) { + return super.addEventListener(type, callback, options); + } + off(type, callback) { + return super.removeEventListener(type, callback); + } + } + + /** + * this singleton keeps track of events in order to: + * 1) Report the "last" value for a given CC/note + * 2) Track "active" notes + * 3) Keep short term memory of events to avoid missing events because of scratch's slower event sample rate + * 4) Allow recording events for playback/storage + * + * It keeps track of midi events, automatically pruning them after a certain time period or # of max entries have been reached (lazy, it doesn't actively poll) + */ + class MidiRecorder extends EventTarget { + constructor() { + super(); + this.active = /* @__PURE__ */ new Map(); + // REVIEW should this just be a {[key: channel]: Map()} instead? + this.activeByChannel = /* @__PURE__ */ new Map(); + this.ccs = {}; + this.lastNotes = {}; + this.buffer = []; + this.paused = false; + this.recordStart = 0; + // 5 minute record time should be safe...midi doesn't take up much memory + this.bufferSeconds = 60 * 5; + this.maxEntries = 256 * 256; + } + _now() { + return ( + (globalThis.performance ? globalThis.performance.now() : Date.now()) / + 1000 + ); + } + /** + * REVIEW should record start be a protection against purging? + */ + startRecording(waitForEvent = true) { + if (!waitForEvent) { + this.recordStart = this._now(); + } + this.paused = false; + } + /** + * REVIEW should stop recording pause collecting events? probably bad + */ + stopRecording() { + this.paused = true; + const { recordStart } = this; + this.recordStart = 0; + return recordStart + ? this.buffer.filter((evt) => evt.when >= recordStart) + : this.buffer; + } + /** + * add note to buffer + * @param evt + * @param when -- note, only used for setting "when" of active notes...evt should already have time value + * @returns + */ + add(evt, when = this._now()) { + const doc = { ...evt, when }; + if (!this.paused) { + this.buffer.push(doc); + this._prune(when); + } + switch (evt.type) { + case "note": + this._onNoteOn(evt); + break; + case "noteOff": + this._onNoteOff(doc); + break; + case "polyTouch": + this._onNoteTouch(doc); + break; + case "cc": + Object.assign(this.ccs, { + [`${evt.cc}_${evt.channel}`]: evt, + [`${evt.cc}`]: evt, + }); + break; + } + } + _onNoteOn(evt) { + if (evt.pitch == undefined) return; + this.active.set(evt.pitch, evt); + this.lastNotes[`${evt.pitch}`] = evt; + if (evt.channel) { + this.lastNotes[`${evt.pitch}_${evt.channel}`] = evt; + } + this.activeByChannel.set(channelKeyForEvent(evt.pitch, evt.channel), evt); + } + _onNoteOff(evt) { + const { pitch, channel } = evt; + if (pitch == undefined) return; + const key = channelKeyForEvent(pitch, channel); + const existing = this.activeByChannel.get(key) || this.active.get(pitch); + if (!existing) return; + existing.duration = evt.when - existing.when; + this.active.delete(pitch); + this.activeByChannel.delete(key); + } + _onNoteTouch(evt) { + const { pitch, channel } = evt; + if (pitch == undefined) return; + const key = channelKeyForEvent(pitch, channel); + const existing = this.activeByChannel.get(key) || this.active.get(pitch); + if (!existing) return; + existing.aftertouch = evt.value; + } + isNoteActive(pitch, channel) { + if (channel == undefined) { + return this.active.has(pitch); + } + const key = channelKeyForEvent(pitch, channel); + return this.activeByChannel.has(key); + } + getActiveNote(pitch, channel) { + if (pitch == undefined) { + const list = this.getActiveNotes(channel); + return list.length == 0 + ? undefined + : list.reduce((a, b) => (a.when > b.when ? a : b)); + } + if (channel == undefined) { + return this.active.get(pitch); + } + const key = channelKeyForEvent(pitch, channel); + return this.activeByChannel.get(key); + } + getActiveNotes(channel) { + if (channel == undefined) { + return [...this.active.values()]; + } + return [...this.activeByChannel.values()].filter( + (c) => c.channel == channel + ); + } + clear(newStartTime = 0) { + this.lastNotes = {}; + this.active.clear(); + this.activeByChannel.clear(); + this.ccs = {}; + this.recordStart = newStartTime; + // remove old events + this._prune(newStartTime + this.bufferSeconds); + } + getRange(start = this.recordStart ?? 0, end) { + const first = this.buffer.findIndex((e) => (e.time ?? e.when) >= start); + if (!end) { + return this.buffer.slice(first); + } + const last = findLastIndex(this.buffer, (e) => (e.time ?? e.when) <= end); + return this.buffer.slice(first, last); + } + getLastEvent(channel) { + return this.getLast(undefined, undefined, channel); + } + getLastNote(pitch, channel) { + return this.getLast("note", pitch, channel); + } + getLastCC(cc, channel) { + return this.getLast("cc", cc, channel); + } + getLastAftertouch(pitch, channel) { + return this.getLast("polyTouch", pitch, channel); + } + getLast(type, value1, channel) { + // shortcut - just get last event if no filter + if (type == undefined && value1 == undefined && channel == undefined) { + return this.buffer[this.buffer.length - 1]; + } + if (type === "cc" && value1) { + const key = channel != undefined ? `${value1}_${channel}` : `${value1}`; + const foundCC = this.ccs[key]; + if (foundCC) return foundCC; + } + for (let i = this.buffer.length - 1; i >= 0; i--) { + const evt = this.buffer[i]; + if (value1 != undefined && evt.value1 != value1) continue; + if (channel != undefined && evt.channel != channel) continue; + if (type) { + const eType = evt.type; + const isType = Array.isArray(type) + ? type.includes(eType) + : type === eType; + if (!isType) continue; + } + return evt; + } + } + *streamLast(type) { + for (let i = this.buffer.length; i >= 0; i--) { + const evt = this.buffer[i]; + if (type && evt.type != type) continue; + yield evt; + } + } + _prune(when = this._now()) { + this.recordStart || (this.recordStart = when); + const threshold = when - this.bufferSeconds; + if (this.buffer.length > this.maxEntries) { + this.buffer = this.buffer.slice(-1 * this.maxEntries); + } + const firstNonStaleEvent = this.buffer.findIndex( + (e) => e.when >= threshold + ); + if (firstNonStaleEvent === -1) { + this.buffer = []; + } else { + this.buffer = this.buffer.slice(firstNonStaleEvent); + } + } + } + // polyfill for findLastIndex for 5% of browsers without it + const findLastIndex = (arr, cb) => { + if (typeof arr.findLastIndex === "function") { + return arr.findLastIndex(cb); + } + let i = arr.length - 1; + while (i > -1 && !cb(arr[i], i, arr)) { + i -= 1; + } + return i; + }; + + function channelKeyForEvent(value = 0, channel = 0) { + return msbLsbToValue(value, channel); + } + + // src/midi/midi-thread.ts + const TARGET_MIDI_KEY = "_midi"; + /** + * + * @param {import("scratch-vm").Thread} thread + * @returns {MidiEvent | undefined} + */ + function getThreadMidiValue(thread) { + return thread[TARGET_MIDI_KEY]; + } + function setThreadMidiValue(thread, evt) { + thread[TARGET_MIDI_KEY] = evt; + } + function setThreadActiveNotes(thread, value) { + thread._activeNotes = value; + } + function getThreadActiveNotes(thread) { + return thread._activeNotes; + } + /** + * Get the current tempo. + * @return {number} - the current tempo, in beats per minute. + */ + function getTempo() { + const stage = Scratch.vm.runtime.getTargetForStage(); + if (stage) { + return stage.tempo; + } + return 60; + } + function beatsToSeconds(beats = 1, tempo = getTempo()) { + return (beats * 60) / tempo; + } + // function secondsToBeats(seconds = 1, tempo = getTempo()) { + // return (seconds * tempo) / 60; + // } + + //#endregion wrapper + + //#region Scratch Extension + + // hardcoded mapping of hats events, b/c I'll never remember the convention otherwise + const HATS = { + DEVICE: `${EXT_ID}_whenDeviceEvent`, + NOTE: `${EXT_ID}_whenNoteOnOff`, + NOTEANY: `${EXT_ID}_whenAnyNoteOnOff`, + MIDI: `${EXT_ID}_whenMidiEvent`, + // not currently implemented + // CC: `${EXT_ID}_whenCC` + }; + /** + * Block separator constant + * @type {'---'} + */ + const SEPARATOR = "---"; + + class MidiExtension { + getInfo() { + const EVENT_TYPES_ITEMS = [ + { + value: "note", + text: Scratch.translate("Note On"), + }, + { + value: "noteOff", + text: Scratch.translate("Note Off"), + }, + { + value: "cc", + text: Scratch.translate("CC"), + }, + { + value: "polyTouch", + text: Scratch.translate("AfterTouch"), + }, + { + value: "pitchBend", + text: Scratch.translate("Pitch Bend"), + }, + { + value: "programChange", + text: Scratch.translate("Program Change"), + }, + { + value: "channelPressure", + text: Scratch.translate("Channel Pressure"), + }, + { + value: "meta", + text: Scratch.translate("Midi File Meta"), + }, + ]; + + return { + id: EXT_ID, + name: Scratch.translate("Midi"), + menuIconURI: + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjEuNzc1IDcuNTE3SDI0djguOTY2aC0yLjIyNXptLTguNTYyIDBoNi41MDZjLjY2IDAgMS4wNDUuNTcgMS4wNDUgMS4yNDd2Ni42MDdjMCAuODQtLjM1IDEuMTEyLTEuMTEyIDEuMTEyaC02LjQzOXYtNS42OTZoMi4yMjV2My41MDVoMy4xMzVWOS41NGgtNS4zNnptLTMuMjM1IDBoMi4xOXY4Ljk2NmgtMi4xOXpNMCA3LjUxN2g3Ljg1NGMuNjYgMCAxLjA0NS41NyAxLjA0NSAxLjI0N3Y3LjcySDYuNzA4VjkuNzc0SDUuNDI3djYuNzA4SDMuNDM4VjkuNzc1SDIuMTkxdjYuNzA4SDBaIiBmaWxsPSIjMDAwIj48L3BhdGg+PC9zdmc+", + blockIconURI: + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjEuNzc1IDcuNTE3SDI0djguOTY2aC0yLjIyNXptLTguNTYyIDBoNi41MDZjLjY2IDAgMS4wNDUuNTcgMS4wNDUgMS4yNDd2Ni42MDdjMCAuODQtLjM1IDEuMTEyLTEuMTEyIDEuMTEyaC02LjQzOXYtNS42OTZoMi4yMjV2My41MDVoMy4xMzVWOS41NGgtNS4zNnptLTMuMjM1IDBoMi4xOXY4Ljk2NmgtMi4xOXpNMCA3LjUxN2g3Ljg1NGMuNjYgMCAxLjA0NS41NyAxLjA0NSAxLjI0N3Y3LjcySDYuNzA4VjkuNzc0SDUuNDI3djYuNzA4SDMuNDM4VjkuNzc1SDIuMTkxdjYuNzA4SDBaIiBmaWxsPSIjRkZGIj48L3BhdGg+PC9zdmc+", + color1: "#4C97FF", + color2: "#337BCC", + color3: "#2C6CA3", + blocks: [ + { + opcode: "whenDeviceEvent", + text: Scratch.translate("when [DEVICE_TYPE] device [STATE]"), + blockType: Scratch.BlockType.EVENT, + isEdgeActivated: false, + shouldRestartExistingThreads: true, + arguments: { + DEVICE_TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "DEVICE_TYPES", + defaultValue: "input", + }, + STATE: { + type: Scratch.ArgumentType.STRING, + menu: "DEVICE_STATES", + defaultValue: "connected", + }, + }, + }, + { + opcode: "numDevices", + text: Scratch.translate("number of [DEVICE_TYPE] devices"), + blockType: Scratch.BlockType.REPORTER, + arguments: { + DEVICE_TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "DEVICE_TYPES", + defaultValue: "input", + }, + }, + }, + { + opcode: "getDeviceInfo", + text: Scratch.translate( + "[DEVICE_PROP] of [DEVICE_TYPE] device at [INDEX]" + ), + blockType: Scratch.BlockType.REPORTER, + arguments: { + DEVICE_PROP: { + type: Scratch.ArgumentType.STRING, + menu: "DEVICE_PROPS", + defaultValue: "name", + }, + DEVICE_TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "DEVICE_TYPES", + defaultValue: "input", + }, + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1, + }, + }, + }, + SEPARATOR, + { + opcode: "whenNoteOnOff", + blockType: Scratch.BlockType.HAT, + text: Scratch.translate("when note [NOTE] [PRESS]"), + isEdgeActivated: false, + shouldRestartExistingThreads: true, + arguments: { + NOTE: { + type: Scratch.ArgumentType.NOTE, + defaultValue: 60, + }, + PRESS: { + type: Scratch.ArgumentType.STRING, + menu: "NOTE_EVENT_TYPE", + defaultValue: "ANY", + }, + }, + }, + { + opcode: "whenAnyNoteOnOff", + blockType: Scratch.BlockType.HAT, + text: Scratch.translate("when any note [PRESS]"), + isEdgeActivated: false, + shouldRestartExistingThreads: true, + arguments: { + PRESS: { + type: Scratch.ArgumentType.STRING, + menu: "NOTE_EVENT_TYPE", + defaultValue: "ANY", + }, + }, + }, + { + opcode: "whenMidiEvent", + blockType: Scratch.BlockType.HAT, + text: Scratch.translate("when input event [TYPE]"), + isEdgeActivated: false, + shouldRestartExistingThreads: true, + arguments: { + TYPE: { + menu: "EVENT_TYPES_OPTIONAL", + type: Scratch.ArgumentType.STRING, + defaultValue: "ANY", + }, + }, + }, + { + opcode: "lastEvent", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Input Event"), + disableMonitor: true, + allowDropAnywhere: true, + }, + { + opcode: "getEventProp", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("[PROP] of [EVENT]"), + arguments: { + EVENT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "note C4 82 ch1 dev0 @1000", + }, + PROP: { + type: Scratch.ArgumentType.STRING, + defaultValue: "type", + menu: "PROPS", + }, + }, + }, + { + opcode: "isNoteActive", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("Is Note [NOTE] Pressed?"), + arguments: { + NOTE: { + type: Scratch.ArgumentType.NOTE, + defaultValue: 60, + }, + CHANNEL: { + type: Scratch.ArgumentType.STRING, + menu: "CHANNELS", + defaultValue: "0", + }, + }, + }, + { + opcode: "isEventOfType", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("Is input event [TYPE]?"), + arguments: { + TYPE: { + menu: "EVENT_TYPES", + type: Scratch.ArgumentType.STRING, + defaultValue: "note", + }, + }, + }, + SEPARATOR, + { + opcode: "getActiveNotes", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Active Notes"), + }, + { + opcode: "numActiveNotes", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("length of active notes"), + }, + { + opcode: "getActiveNoteByIndex", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Note [INDEX] of active notes"), + arguments: { + INDEX: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0, + }, + }, + }, + { + opcode: "setActiveNoteList", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("Set list [LIST] to Active Notes"), + arguments: { + LIST: { + type: Scratch.ArgumentType.STRING, + menu: "lists", + }, + }, + }, + SEPARATOR, + { + opcode: "playNoteForBeats", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("play note [NOTE] for [BEATS] beats"), + arguments: { + NOTE: { + type: Scratch.ArgumentType.NOTE, + defaultValue: 60, + }, + BEATS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.25, + }, + }, + }, + { + opcode: "sendOutputEvent", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("Send [EVENT] to [DEVICE]"), + arguments: { + EVENT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "Eb4 beats=1/4", + }, + DEVICE: { + type: Scratch.ArgumentType.STRING, + menu: "OUTPUT_DEVICES", + }, + }, + }, + { + opcode: "makeOutputNote", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "Note [NOTE] Duration [BEATS] Volume [VELOCITY]% [CHANNEL] [DEVICE]" + ), + arguments: { + NOTE: { + type: Scratch.ArgumentType.NOTE, + defaultValue: 60, + }, + BEATS: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0.25, + }, + VELOCITY: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 96, + }, + CHANNEL: { + type: Scratch.ArgumentType.STRING, + menu: "CHANNELS", + defaultValue: "1", + }, + DEVICE: { + type: Scratch.ArgumentType.STRING, + menu: "OUTPUT_DEVICES", + }, + }, + }, + { + opcode: "makeOutputEvent", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "Event [TYPE] [VALUE1] [VALUE2] [CHANNEL] [DEVICE]" + ), + arguments: { + TYPE: { + menu: "EVENT_TYPES", + type: Scratch.ArgumentType.STRING, + defaultValue: "cc", + }, + VALUE1: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0, + }, + VALUE2: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0, + }, + CHANNEL: { + type: Scratch.ArgumentType.STRING, + menu: "CHANNELS", + defaultValue: "1", + }, + DEVICE: { + type: Scratch.ArgumentType.STRING, + menu: "OUTPUT_DEVICES", + }, + }, + }, + { + opcode: "stopAllNotes", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("stop all notes on output devices"), + }, + SEPARATOR, + { + opcode: "parseMidiUrl", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Parse MIDI from url: [PATH]"), + arguments: { + PATH: { + type: Scratch.ArgumentType.STRING, + defaultValue: ".mid", + }, + }, + }, + { + opcode: "setListToMidi", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "Set list [LIST] to [FILTER] in [MIDIDATA]" + ), + arguments: { + LIST: { + type: Scratch.ArgumentType.STRING, + menu: "lists", + }, + FILTER: { + type: Scratch.ArgumentType.STRING, + menu: "MIDI_FILTER", + defaultValue: "ANY", + }, + MIDIDATA: { + type: Scratch.ArgumentType.STRING, + }, + }, + }, + SEPARATOR, + { + opcode: "noteForName", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("MIDI Note # [NOTE]"), + arguments: { + NOTE: { + type: Scratch.ArgumentType.STRING, + menu: "NOTE_NAMES", + defaultValue: "C4", + }, + }, + }, + { + opcode: "nameForNote", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Pitch Name of [NOTE] (use [ACCIDENTAL])"), + arguments: { + NOTE: { + type: Scratch.ArgumentType.NOTE, + defaultValue: 60, + }, + ACCIDENTAL: { + type: Scratch.ArgumentType.STRING, + menu: "ACCIDENTALS", + defaultValue: "sharps", + }, + }, + }, + { + opcode: "normalizeMidiVal", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate( + "Scale MIDI Value [VALUE] to min=[MIN] max=[MAX]" + ), + arguments: { + VALUE: { type: Scratch.ArgumentType.NUMBER, defaultValue: 0 }, + MIN: { type: Scratch.ArgumentType.NUMBER, defaultValue: -1 }, + MAX: { type: Scratch.ArgumentType.NUMBER, defaultValue: 1 }, + }, + }, + { + opcode: "eventToJSON", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Convert MIDI Event [EVENT] to JSON"), + arguments: { + EVENT: { + type: Scratch.ArgumentType.STRING, + defaultValue: "note Eb4 96 t=0.5 dur=0.25", + }, + }, + }, + { + opcode: "jsonToEvent", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("Convert JSON [TEXT] to MIDI Event"), + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: + '{"type":"note","channel":1,"device":0,"pos":0.25,"dur":0.25,"pitch":52,"velocity":96}', + }, + }, + }, + SEPARATOR, + { + opcode: "getMidiStartTime", + text: Scratch.translate("MIDI Start Time"), + blockType: Scratch.BlockType.REPORTER, + }, + { + opcode: "getMidiCurrentTime", + text: Scratch.translate("MIDI Current Time"), + blockType: Scratch.BlockType.REPORTER, + }, + { + opcode: "clearEvents", + text: Scratch.translate("Clear event buffer"), + blockType: Scratch.BlockType.COMMAND, + arguments: { + TIME: { + type: Scratch.ArgumentType.NUMBER, + }, + }, + }, + { + opcode: "setMidiEventList", + text: Scratch.translate( + "Set list [LIST] to events in last [TIME] seconds" + ), + blockType: Scratch.BlockType.COMMAND, + arguments: { + LIST: { + type: Scratch.ArgumentType.STRING, + menu: "lists", + }, + TIME: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 5, + }, + }, + }, + ], + menus: { + EVENT_TYPES: { + acceptReporters: true, + items: EVENT_TYPES_ITEMS, + }, + EVENT_TYPES_OPTIONAL: { + acceptReporters: true, + items: [ + ...EVENT_TYPES_ITEMS, + { + value: "ANY", + text: Scratch.translate("Any"), + }, + ], + }, + INPUT_DEVICES: { + acceptReporters: true, + items: "inputDevicesMenu", + }, + OUTPUT_DEVICES: { + acceptReporters: true, + items: "outputDevicesMenu", + }, + DEVICE_TYPES: { + acceptReporters: false, + items: [ + { + value: "input", + text: Scratch.translate("input"), + }, + { + value: "output", + text: Scratch.translate("output"), + }, + ], + }, + DEVICE_PROP: { + acceptReporters: true, + items: [ + { + value: "id", + text: Scratch.translate("Device ID"), + }, + { + value: "manufacturer", + text: Scratch.translate("Manufacturer"), + }, + { + value: "name", + text: Scratch.translate("Device Name"), + }, + { + value: "state", + text: Scratch.translate("State"), + }, + ], + }, + DEVICE_STATES: { + acceptReporters: false, + items: [ + { + value: "connected", + text: Scratch.translate("connected"), + }, + { + value: "disconnected", + text: Scratch.translate("disconnected"), + }, + ], + }, + NOTE_EVENT_TYPE: { + acceptReporters: true, + items: [ + { + value: "ANY", + text: Scratch.translate("Note On/Off"), + }, + { + value: "note", + text: Scratch.translate("Note On"), + }, + { + value: "noteOff", + text: Scratch.translate("Note Off"), + }, + ], + }, + ACCIDENTALS: { + acceptReporters: true, + items: [ + { + value: "sharps", + text: `♯ ${Scratch.translate("Sharps")}`, + }, + { + value: "flats", + text: `♭ ${Scratch.translate("Flats")}`, + }, + { + value: "num", + text: `${Scratch.translate("MIDI Number")}`, + }, + ], + }, + NOTE_NAMES: { + acceptReporters: true, + // the result of these don't need translation - they're just "C4" or "G#6" + items: [...MIDI_NOTES.entries()].map(([index, text]) => ({ + value: `${index}`, + text, + })), + }, + CHANNELS: { + acceptReporters: true, + items: [ + { + value: "0", + text: Scratch.translate("any channel"), + }, + ..."0" + .repeat(15) + .split("") + .map((_, i) => ({ + value: `${i + 1}`, + text: `channel ${i + 1}`, + })), + ], + }, + PROPS: { + acceptReporters: true, + items: Object.entries(EVENT_PROPS).map(([value, { text }]) => ({ + value, + text, + })), + }, + DEVICE_PROPS: { + acceptReporters: true, + items: [ + { + value: "id", + text: Scratch.translate("Id"), + }, + { + value: "name", + text: Scratch.translate("Name"), + }, + { + value: "state", + text: Scratch.translate("State"), + }, + { + value: "manufacturer", + text: Scratch.translate("Manufacturer"), + }, + ], + }, + MIDI_FILTER: { + acceptReporters: true, + // TODO dedupe this with EVENT_TYPES? + items: [ + { + value: "ANY", + text: Scratch.translate("all events"), + }, + { + value: "note", + text: Scratch.translate("all Notes"), + }, + { + value: "meta", + text: Scratch.translate("MIDI File Meta"), + }, + { + value: "programChange", + text: Scratch.translate("Program Changes"), + }, + { + value: "cc", + text: Scratch.translate("CC"), + }, + { + value: "pitchBend", + text: Scratch.translate("Pitch Bends"), + }, + ], + }, + // taken from /Lily/ListTools.js + lists: { + acceptReporters: true, + items: "_getLists", + }, + }, + }; + } + constructor() { + this.midi = new MidiBackend(); + this.recorder = new MidiRecorder(); + /** + * Lazy initialize midi - only upon first executing, and used in other calls to make sure it gets triggered + */ + this._ensureInitialize = (evt) => { + if (this.midi.status === "pending") { + this.initialize().catch(() => {}); + } + }; + this._addListeners(); + // globalThis.midiExt = this; + const vm = Scratch.vm; + // REVIEW this will end up always requesting midi permissions even if just using this extension for midi files. does this matter? + vm.runtime.once("BEFORE_EXECUTE", this._ensureInitialize); + } + /** + * This helper actually glues together the extension with the MidiBackend / recorder. In particular it triggers HATS when incoming midi events occur + */ + _addListeners() { + const vm = globalThis.Scratch.vm; + this.midi.on("device:status", (domEvent) => { + const changeEvent = domEvent.detail; + vm.runtime.startHats(HATS.DEVICE, { + DEVICE_TYPE: changeEvent.type, + STATE: changeEvent.state, + }); + }); + this.midi.on("midi", (domEvent) => { + const midiEvent = domEvent.detail; + this.recorder.add(midiEvent); + const threads = vm.runtime.startHats(HATS.MIDI); + // set the thread local variable to the passed in midi event for use with the "input event" reporter + for (let thread of threads) { + setThreadMidiValue(thread, midiEvent); + } + switch (midiEvent.type) { + case "note": + case "noteOff": + vm.runtime.startHats(HATS.NOTE); + vm.runtime.startHats(HATS.NOTEANY); + break; + // FUTURE - support a "whenCC" block if needed + // case "cc": + // vm.runtime.startHats(HATS.CC); + // break; + } + }); + } + // taken from /Lily/ListTools.js + _getLists() { + if (typeof Blockly === "undefined") { + return [""]; + } + const lists = Blockly.getMainWorkspace() + .getVariableMap() + // @ts-expect-error - Blockly not typed yet + .getVariablesOfType("list") + .map((model) => model.name); + + if (lists.length > 0) { + return lists; + } else { + return [""]; + } + } + async initialize({ sysex = false, FORCE = false } = {}, util) { + const force = Scratch.Cast.toBoolean(FORCE); + await this.midi.initialize({ sysex, force }); + } + /** + * Checks if input value is whitespace or '*' or 'any'. + * @param {unknown} val + * @returns {val is "ANY"} + */ + _isAnyArg(val) { + return ( + val === "ANY" || /^(\*|any|\s*)$/i.test(Scratch.Cast.toString(val)) + ); + } + _getDeviceIndex(DEVICE, deviceType) { + const deviceId = Scratch.Cast.toString(DEVICE); + const deviceList = + deviceType === "output" ? this.midi.outputs : this.midi.inputs; + + const rawIndex = this.midi.outputs.findIndex((d) => d.id === deviceId); + if (rawIndex !== -1) { + // arrays start at 1 + return rawIndex + 1; + } + // check if index passed in and use directly + const index = Scratch.Cast.toNumber(deviceId); + if (index >= 1 && index <= deviceList.length) { + return index; + } + return undefined; + } + numDevices({ DEVICE_TYPE }) { + this._ensureInitialize(); + const deviceType = Scratch.Cast.toString(DEVICE_TYPE).toLowerCase(); + const deviceList = + deviceType === "output" ? this.midi.outputs : this.midi.inputs; + + return deviceList.length; + } + numOutputDevices() { + this._ensureInitialize(); + return this.midi.outputs.length; + } + getDeviceInfo({ DEVICE_TYPE, INDEX, DEVICE_PROP }, util) { + this._ensureInitialize(); + const deviceType = Scratch.Cast.toString(DEVICE_TYPE).toLowerCase(); + const deviceList = + deviceType === "input" ? this.midi.inputs : this.midi.outputs; + const index = Scratch.Cast.toListIndex(INDEX, deviceList.length, false); + const device = + typeof index === "number" ? deviceList[index - 1] : undefined; + if (!device) return ""; + const prop = Scratch.Cast.toString(DEVICE_PROP); + return device[prop] ?? ""; + } + inputDevicesMenu() { + const inputList = this.midi.inputs.map((d) => ({ + text: d.name || d.id, + value: d.id, + })); + return [{ text: Scratch.translate("any"), value: "ANY" }].concat( + inputList + ); + } + outputDevicesMenu() { + const outputList = this.midi.outputs.map((d) => ({ + text: d.name || d.id, + value: d.id, + })); + return [{ text: Scratch.translate("any"), value: "ANY" }].concat( + outputList + ); + } + // handled automatically b/c blockType = EVENT instead of HAT + // whenDeviceEvent({ DEVICE_TYPE, STATE }, util) { + // } + whenNoteOnOff({ NOTE, PRESS }, util) { + const isAny = this._isAnyArg(PRESS); + let type = isAny + ? ["note", "noteOff"] + : [normalizeType(Scratch.Cast.toString(PRESS))]; + + const pitch = this._isAnyArg(NOTE) + ? undefined + : Scratch.Cast.toNumber(NOTE); + const last = this.recorder.getLast(); + + // filter if only note on or note off + if ( + last && + type.includes(last.type) && + (pitch === undefined || last.pitch === pitch) + ) { + setThreadMidiValue(util.thread, last); + return true; + } + + return false; + } + whenAnyNoteOnOff({ PRESS }, util) { + const isAny = this._isAnyArg(PRESS); + let type = isAny + ? ["note", "noteOff"] + : [normalizeType(Scratch.Cast.toString(PRESS))]; + + const last = this.recorder.getLast(); + // filter if only note on or note off + if (last && type.includes(last.type)) { + setThreadMidiValue(util.thread, last); + return true; + } + + return false; + } + whenMidiEvent({ TYPE }, util) { + const isAny = this._isAnyArg(TYPE); + const type = isAny + ? undefined + : normalizeType(Scratch.Cast.toString(TYPE)); + const last = this.recorder.getLast(); + if (last && (isAny || last.type === type)) { + setThreadMidiValue(util.thread, last); + return !!last; + } + return false; + } + playNoteForBeats({ NOTE, BEATS }, util) { + let text = Scratch.Cast.toString(NOTE); + const beats = Scratch.Cast.toString(BEATS); + + // just append to text and let stringToMidi handle + if (beats) { + text += ` beats=${beats}`; + } + + // default event type is note, so any valid input will be treated as note by default + const event = stringToMidi(`${text}`); + this.midi.sendOutputEvent(event); + } + sendOutputEvent({ EVENT, DEVICE }, util) { + const text = Scratch.Cast.toString(EVENT); + const event = stringToMidi(text); + + if (DEVICE) { + event.device = this._getDeviceIndex(DEVICE, "output"); + } + + this.midi.sendOutputEvent(event); + } + stopAllNotes() { + this.midi.panic(); + } + getEventProp({ EVENT, PROP }, util) { + const propName = Scratch.Cast.toString(PROP); + const { key = "value" } = EVENT_PROPS[propName] ?? {}; + if (!EVENT) return ""; + const last = getThreadMidiValue(util.thread); + // use cached thread value if available instead of re-parsing + const evt = last?._str === EVENT ? last : stringToMidi(EVENT); + + return evt?.[key] ?? ""; + } + lastEvent(_args, util) { + let last = getThreadMidiValue(util.thread); + last || (last = this.recorder.getLast()); + return last ? midiToString(last) : ""; + } + isNoteActive({ NOTE, CHANNEL }, util) { + const pitch = this._isAnyArg(NOTE) + ? undefined + : Scratch.Cast.toNumber(NOTE); + const channel = Scratch.Cast.toNumber(CHANNEL) || undefined; + if (pitch == undefined) { + const notes = this.recorder.getActiveNotes(channel); + setThreadActiveNotes(util.thread, { channel, notes }); + return notes.length == 0 + ? undefined + : notes.reduce((a, b) => (a.when > b.when ? a : b)); + } + const note = this.recorder.getActiveNote(pitch, channel || undefined); + if (note) { + setThreadMidiValue(util.thread, note); + } + return !!note; + } + isEventOfType({ TYPE }, util) { + const type = this._isAnyArg(TYPE) + ? undefined + : Scratch.Cast.toString(TYPE); + let last = getThreadMidiValue(util.thread); + last || (last = this.recorder.getLast()); + return last?.type === type; + } + noteForName({ NOTE }) { + if (typeof NOTE === "number" || /^\d+$/.test(NOTE)) { + return Scratch.Cast.toNumber(NOTE); + } + const name = Scratch.Cast.toString(NOTE); + return noteNameToMidiPitch(name) || 0; + } + nameForNote({ NOTE, ACCIDENTAL }, util) { + const pitchFormat = Scratch.Cast.toString(ACCIDENTAL).toLowerCase(); + let val = Scratch.Cast.toNumber(NOTE); + if (!val && /^[a-g]/i.test(`${NOTE}`)) { + val = noteNameToMidiPitch(Scratch.Cast.toString(NOTE)) || 0; + } + return midiPitchToNoteName(val, { pitchFormat }); + } + makeOutputNote({ NOTE, BEATS, VELOCITY, CHANNEL, DEVICE }) { + let beats = + typeof BEATS === "string" && BEATS.includes("/") + ? parseFraction(BEATS) + : Scratch.Cast.toNumber(BEATS); + /** sanity check - clamp beats as per scratch music extension limits + * @see {@link https://github.com/TurboWarp/scratch-vm/blob/develop/src/extensions/scratch3_music/index.js#L706} + */ + beats = Math.max(0, Math.min(beats, 100)); + // convert from percent to midi value + let velocity = (Scratch.Cast.toNumber(VELOCITY) / 100) * 127; + // clamp value + // NOTE - if 0 velocity then nothing will happen because treated as note off + velocity = Math.max(0, Math.min(velocity, 127)); + const device = this._getDeviceIndex(DEVICE, "output"); + /** @type {MidiEvent} */ + const event = { + type: "note", + pitch: this.noteForName({ NOTE }), + velocity, + channel: Scratch.Cast.toNumber(CHANNEL) || undefined, + device, + beats, + }; + return midiToString(event); + } + makeOutputEvent({ TYPE, VALUE1, VALUE2, CHANNEL, DEVICE }) { + /** @type {EventType} */ + // @ts-ignore + let type = Scratch.Cast.toString(TYPE); + // default is CC - is that right? + // @ts-ignore + if (this._isAnyArg(type)) type = "cc"; + let spec = eventMapping[type]; + // UNDOCUMENTED - allow raw number for command. + // note that the command lookup restricts command types, so will exclude sysex messages (which are possible security risk but already blocked by browsers anyways) + if (!spec && /(0x)?[a-f0-9]+/i.test(type)) { + const rawType = /[a-f]/.test(type) + ? parseInt(type, 16) + : parseInt(type); + type = commandLookup[rawType]; + } + + // clamp values + let value1 = Scratch.Cast.toNumber(VALUE1); + // pitchbend can be a bigger number + const maxValue1 = spec?.highResParam ? 16384 : 127; + value1 = Math.max(0, Math.min(value1, maxValue1)); + + let value2 = Scratch.Cast.toNumber(VALUE2); + value2 = Math.max(0, Math.min(value2, 127)); + + // REVIEW - should OUTPUT_DEVICES be changed to output device index instead of id? + const device = this._getDeviceIndex(DEVICE, "output"); + // FUTURE this may make sense to get moved out to helper alongside rawMessageToMidi + /** @type {MidiEvent} */ + const event = { + type, + value1, + value2, + channel: Scratch.Cast.toNumber(CHANNEL) || undefined, + device, + ...(spec?.param1 && { [spec.param1]: value1 }), + ...(spec?.param2 && { [spec.param2]: value2 }), + ...(spec?.highResParam && parseHighResValue(value1, value2)), + }; + return midiToString(event); + } + normalizeMidiVal({ VALUE, MIN, MAX }) { + const min = Scratch.Cast.toNumber(MIN); + const max = Scratch.Cast.toNumber(MAX); + const val = Scratch.Cast.toNumber(VALUE) / 127; + return val * (max - min) + min; + } + getMidiStartTime() { + return this.recorder.recordStart; + } + getMidiCurrentTime() { + return this.recorder._now(); + } + clearEvents(args, util) { + this.recorder.clear(); + } + setMidiEventList({ LIST, TIME }, util) { + const duration = Scratch.Cast.toNumber(TIME) || 5; + const start = this.recorder._now() - duration; + const events = this.recorder.getRange(start); + if (LIST) { + this._upsertList( + Scratch.Cast.toString(LIST), + events.map((evt) => evt._str), + util.target + ); + } + } + getActiveNotes(args, util) { + const notes = Array.from(this.recorder.getActiveNotes()); + setThreadActiveNotes(util.thread, { notes }); + return notes.map((note) => note._str ?? midiToString(note)).join("\n"); + } + getActiveNoteByIndex({ INDEX }, util) { + let active = getThreadActiveNotes(util.thread); + if (!active) { + active = { notes: this.recorder.getActiveNotes() }; + setThreadActiveNotes(util.thread, active); + } + let index = Scratch.Cast.toListIndex(INDEX, active.length, true); + if (!active.notes || index === "INVALID") { + return ""; + } + if (index === "ALL") { + index = 1; + } + const note = active.notes[index - 1]; + if (note) { + setThreadMidiValue(util.thread, note); + return note._str; + } + return ""; + } + setActiveNoteList({ LIST }, util) { + const notes = Array.from(this.recorder.getActiveNotes()); + setThreadActiveNotes(util.thread, { notes }); + if (LIST) { + this._upsertList( + Scratch.Cast.toString(LIST), + notes.map((note) => note._str), + util.target + ); + } + } + _upsertList(name, value, target) { + const vm = globalThis.Scratch.vm; + const stageTarget = vm.runtime.getTargetForStage(); + let listObj = stageTarget?.lookupVariableByNameAndType(name, "list"); + listObj || (listObj = target?.lookupVariableByNameAndType(name, "list")); + if (listObj) { + listObj.value = value; + } + } + numActiveNotes(args, util) { + let active = getThreadActiveNotes(util.thread); + if (!active) { + active = { notes: this.recorder.getActiveNotes() }; + setThreadActiveNotes(util.thread, active); + } + return active.notes.length; + } + eventToJSON({ EVENT }, util) { + const raw = Scratch.Cast.toString(EVENT); + // NOTE will be null if could not parse + const event = stringToMidi(raw); + + // REVIEW - remove value1/value2 if already specified by event spec? + if (event && ("pitch" in event || "value" in event)) { + delete event.value1; + delete event.value2; + } + // QUESTION does this need an error handler? + return JSON.stringify(event); + } + jsonToEvent({ TEXT }, util) { + const raw = Scratch.Cast.toString(TEXT); + // ignore empty inputs + if (raw === "" || raw === "0") return ""; + + let event = null; + try { + event = JSON.parse(raw); + } catch (error) { + event = stringToMidi(raw); + } + + if (Array.isArray(event)) { + return event.map((e) => midiToString(e)).join("\n"); + } + if (event == null) { + return ""; + } + if (typeof event !== "object") { + return midiToString(stringToMidi(event)); + } + // rename aliases just in case + [ + ["beats", "dur"], + ["offset", "pos"], + ["@", "time"], + ] + .filter(([alias, key]) => alias in event && event[key] == undefined) + .forEach(([alias, key]) => (event[key] = event[alias])); + + // normalize type and default to note if not otherwise specified + let type = + event.type === REST_LITERAL ? "rest" : normalizeType(event.type); + if (!type && event.pitch) type = "note"; + return event ? midiToString(event) : ""; + } + /** MIDI FILE FUNCTIONS **/ + async parseMidiUrl({ PATH }, util) { + // cribbed from sound.js + try { + const url = Scratch.Cast.toString(PATH).trim(); + if (!url) return ""; + const midiFile = await fileManager.fetchMidiUrl(url); + const eventData = fileManager.parseMidiData(midiFile, "ANY"); + + return JSON.stringify(eventData, null, 2); + } catch (e) { + console.warn(`Parse ${PATH} failed`, e); + return ""; + } + } + async setListToMidi({ LIST, FILTER, MIDIDATA }, util) { + let raw = Scratch.Cast.toString(MIDIDATA).trim(); + const filter = Scratch.Cast.toString(FILTER) || "ANY"; + /** @type {MidiEvent[]} */ + let events = []; + if (/^(data|blob|https?)/.test(raw)) { + const midiFile = await fileManager.fetchMidiUrl(raw); + events = fileManager.parseMidiData(midiFile, FILTER); + } else if (/^\s*[[{]/.test(raw)) { + // may fail + try { + events = JSON.parse(raw); + } catch (err) {} + } else { + // TODO COMBAK verify + // NOTE doesn't check for newlines in "text" type fields + events = raw.split(/\s*[\r\n]+\s*/).map((row) => stringToMidi(row)); + } + if (filter !== "ANY") { + events = events.filter(({ type }) => type === filter); + } + if (LIST) { + this._upsertList( + Scratch.Cast.toString(LIST), + // COMBAK no formatting options set + events.filter(Boolean).map((evt) => midiToString(evt)), + util.target + ); + } + } + } + + //#endregion + Scratch.extensions.register(new MidiExtension()); +})(Scratch); diff --git a/package-lock.json b/package-lock.json index 27489ef316..74562f46c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,12 @@ "chokidar": "^4.0.3", "ejs": "^3.1.10", "express": "^4.21.2", - "image-size": "^2.0.0", + "image-size": "^2.0.2", "markdown-it": "^14.1.0" }, "devDependencies": { "@transifex/api": "^7.1.3", - "eslint": "^9.22.0", + "eslint": "^9.24.0", "espree": "^9.6.1", "esquery": "^1.6.0", "prettier": "^3.5.3", @@ -62,9 +62,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -77,9 +77,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -100,9 +100,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -155,9 +155,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", + "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", "dev": true, "license": "MIT", "engines": { @@ -276,8 +276,8 @@ }, "node_modules/@turbowarp/types": { "version": "0.0.13", - "resolved": "git+ssh://git@github.com/TurboWarp/types-tw.git#4b59b95e3b02560ff626a36a871869b89dbe9a48", - "integrity": "sha512-4gHxtg0mMeWU1tJVK62InGL+yprQHbdJ5kyOjVtEBAhzX8hXQibcfYCpg0Sdh7QSjrEZ4VVNK3HbGh6cYaPYnQ==", + "resolved": "git+ssh://git@github.com/TurboWarp/types-tw.git#79ab4cc947763b8151e214388985e6a89d1a6cd6", + "integrity": "sha512-EL/75glhoeHvIG/BqtcS15gLJ9f2mcBB9D6uFzlnJMJbvfaCI+NI+OPMYM8tkPsF45/5duFY90nxRQMChlC0Tg==", "license": "Apache-2.0" }, "node_modules/@types/estree": { @@ -706,19 +706,19 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", + "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.24.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -1241,9 +1241,9 @@ } }, "node_modules/image-size": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.0.tgz", - "integrity": "sha512-HP07n1SpdIXGUL4VotUIOQz66MQOq8g7VN+Yj02YTVowqZScQ5i/JYU0+lkNr2pwt5j4hOpk94/UBV1ZCbS2fA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", "license": "MIT", "bin": { "image-size": "bin/image-size.js" diff --git a/package.json b/package.json index 51ce3aa0ed..fa0d5c9eeb 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,12 @@ "chokidar": "^4.0.3", "ejs": "^3.1.10", "express": "^4.21.2", - "image-size": "^2.0.0", + "image-size": "^2.0.2", "markdown-it": "^14.1.0" }, "devDependencies": { "@transifex/api": "^7.1.3", - "eslint": "^9.22.0", + "eslint": "^9.24.0", "espree": "^9.6.1", "esquery": "^1.6.0", "prettier": "^3.5.3", diff --git a/translations/extension-metadata.json b/translations/extension-metadata.json index 0da75cfc3f..0c923a1c9b 100644 --- a/translations/extension-metadata.json +++ b/translations/extension-metadata.json @@ -1280,6 +1280,7 @@ "DNin/wake-lock@description": "Предотвратите переход компьютера в спящий режим.", "DNin/wake-lock@name": "Антисон", "DT/cameracontrols@description": "Переместите видимую часть сцены.", + "DT/cameracontrols@name": "Камера V1", "JeremyGamer13/tween@description": "Упрощенные методы для плавной анимации.", "JeremyGamer13/tween@name": "Плавность", "Lily/AllMenus@description": "Специальная категория с каждым меню из каждой Scratch категории и расширениями.", @@ -1328,6 +1329,7 @@ "PwLDev/vibration@description": "Контролируйте вибрацию устройства. Работает только в Chrome для Android.", "PwLDev/vibration@name": "Вибрация", "SharkPool/Camera@description": "Переместите видимую часть сцены.", + "SharkPool/Camera@name": "Камера V2", "SharkPool/Font-Manager@description": "Добавляйте, удаляйте, и управляйте шрифтами.", "SharkPool/Font-Manager@name": "Менеджер Шрифтов", "Skyhigh173/bigint@description": "Математические блоки, которые работают с бесконечно большими целыми числами (без десятичных).", diff --git a/translations/extension-runtime.json b/translations/extension-runtime.json index f286cd7367..23a5ef6ef0 100644 --- a/translations/extension-runtime.json +++ b/translations/extension-runtime.json @@ -3447,6 +3447,7 @@ "SharkPool/Camera@_move [CAMERA] camera [NUM] steps": "カメラ[CAMERA]を[NUM]歩動かす", "SharkPool/Camera@_move [CAMERA] camera to [TARGET]": "カメラ[CAMERA]を[TARGET]に動かす", "SharkPool/Camera@_myself": "自分自身", + "SharkPool/Camera@_pen layer": "ペンのレイヤー", "SharkPool/Camera@_point [CAMERA] camera towards [TARGET]": "カメラ[CAMERA]を[TARGET]の方向に向ける", "SharkPool/Camera@_rendered x position of [TARGET]": "描かれた[TARGET]のx座標", "SharkPool/Camera@_rendered y position of [TARGET]": "描かれた[TARGET]のy座標", @@ -3710,6 +3711,9 @@ "bitwise@_[LEFT] or [RIGHT]": "[LEFT]または[RIGHT]", "bitwise@_is [CENTRAL] binary?": "[CENTRAL]がバイナリ", "box2d@griffpatch.categoryName": "物理", + "box2d@griffpatch.disablePhysics": "このスプライトの物理演算を無効にする", + "box2d@griffpatch.getGravityX": "x座標方向の重力", + "box2d@griffpatch.getGravityY": "y座標方向の重力", "box2d@griffpatch.getRestitution": "バウンド", "clipboard@_Clipboard": "クリップボード", "clipboard@_clipboard": "クリップボードの内容", @@ -8266,6 +8270,8 @@ "CubesterYT/KeySimulation@_Key Simulation": "Симуляция Клавиш", "CubesterYT/KeySimulation@_backspace": "задний пробел", "CubesterYT/KeySimulation@_caps lock": "лок КАПСА", + "CubesterYT/KeySimulation@_click [BUTTON] mouse button at x: [X] y: [Y] for [SECONDS] seconds and [AND_WAIT]": "нажать [BUTTON] кнопку мыши на x: [X] y: [Y] на [SECONDS] секунд и [AND_WAIT]", + "CubesterYT/KeySimulation@_continue": "продолжить", "CubesterYT/KeySimulation@_control": "контрол", "CubesterYT/KeySimulation@_delete": "удалить", "CubesterYT/KeySimulation@_down arrow": "стрелка вниз", @@ -8280,12 +8286,14 @@ "CubesterYT/KeySimulation@_move mouse to x: [X] y: [Y]": "переместить мышь в x: [X] y: [Y]", "CubesterYT/KeySimulation@_page down": "страница вниз", "CubesterYT/KeySimulation@_page up": "страница вверх", + "CubesterYT/KeySimulation@_press [KEY] for [SECONDS] seconds and [AND_WAIT]": "нажать [KEY] на [SECONDS] секунд и [AND_WAIT]", "CubesterYT/KeySimulation@_right": "правому краю", "CubesterYT/KeySimulation@_right arrow": "стрелка направо", "CubesterYT/KeySimulation@_scroll lock": "лок кручения", "CubesterYT/KeySimulation@_shift": "шифт", "CubesterYT/KeySimulation@_space": "пробле", "CubesterYT/KeySimulation@_up arrow": "стрелка вверх", + "CubesterYT/KeySimulation@_wait": "подождать", "CubesterYT/TurboHook@_content": "контент", "CubesterYT/TurboHook@_icon": "иконка", "CubesterYT/TurboHook@_name": "имя", @@ -8337,6 +8345,7 @@ "DNin/wake-lock@_off": "выключить", "DNin/wake-lock@_on": "включить", "DNin/wake-lock@_turn wake lock [enabled]": "включить режим антисна [enabled]", + "DT/cameracontrols@_Camera V1": "Камера V1", "DT/cameracontrols@_background color": "цвет заднего фона", "DT/cameracontrols@_camera direction": "направление камеры", "DT/cameracontrols@_camera x": "x камеры", @@ -8345,13 +8354,13 @@ "DT/cameracontrols@_change camera x by [val]": "изменить x камеры на [val]", "DT/cameracontrols@_change camera y by [val]": "изменить y камеры на [val]", "DT/cameracontrols@_change camera zoom by [val]": "изменить приближение камеры на [val]", - "DT/cameracontrols@_move camera [val] steps": "пойти камеру на [val] шагов", - "DT/cameracontrols@_move camera to [sprite]": "двинуть камеру на [sprite]", + "DT/cameracontrols@_move camera [val] steps": "камерой идти [val] шагов", + "DT/cameracontrols@_move camera to [sprite]": "переместить камеру на [sprite]", "DT/cameracontrols@_no sprites exist": "никаких спрайтов не существует", "DT/cameracontrols@_point camera towards [sprite]": "навести камеру на [sprite]", "DT/cameracontrols@_set background color to [val]": "задать цвет заднего фона на [val]", "DT/cameracontrols@_set camera direction to [val]": "задать направление камеры на [val]", - "DT/cameracontrols@_set camera to x: [x] y: [y]": "задать позиции камеры на x: [x] y: [y]", + "DT/cameracontrols@_set camera to x: [x] y: [y]": "задать позицию камеры на x: [x] y: [y]", "DT/cameracontrols@_set camera x to [val]": "задать x камеры на [val]", "DT/cameracontrols@_set camera y to [val]": "задать y камеры на [val]", "DT/cameracontrols@_set camera zoom to [val] %": "задать приближение камены в [val]%", @@ -8406,11 +8415,13 @@ "Lily/Assets@_name of sound # [INDEX]": "имя звука # [INDEX]", "Lily/Assets@_open project from URL [URL]": "открыть проект из URL-адреса [URL]", "Lily/Assets@_project JSON": "JSON проекта", + "Lily/Assets@_project [EXPORT]": "проект [EXPORT]", "Lily/Assets@_rename costume [COSTUME] to [NAME]": "переименовать костюм [COSTUME] в [NAME]", "Lily/Assets@_rename sound [SOUND] to [NAME]": "переименовать звук [SOUND] в [NAME]", "Lily/Assets@_rename sprite [TARGET] to [NAME]": "переименовать спрайт [TARGET] в [NAME]", "Lily/Assets@_reorder costume # [INDEX1] to index [INDEX2]": "пересортировать костюм # [INDEX1] в индекс [INDEX2]", "Lily/Assets@_reorder sound # [INDEX1] to index [INDEX2]": "пересортировать звук # [INDEX1] в индекс [INDEX2]", + "Lily/Assets@_sprite [EXPORT]": "спрайт [EXPORT]", "Lily/Assets@_sprite name": "имя спрайта", "Lily/Cast@_Cast": "Каст", "Lily/Cast@_boolean": "логическое", @@ -8777,10 +8788,47 @@ "PwLDev/vibration@_play vibration pattern [PATTERN]": "играть вибрацию с последовательностью [PATTERN]", "PwLDev/vibration@_start vibrating for [SECONDS] seconds": "начать вибрацию на [SECONDS] секунд", "PwLDev/vibration@_stop vibrating": "остановить вибрацию", + "SharkPool/Camera@_Add Camera": "Добавить Камеру", + "SharkPool/Camera@_Camera Controls": "Контроль Камеры", + "SharkPool/Camera@_Camera Manager": "Менеджер Камеры", + "SharkPool/Camera@_Camera V2": "Камера V2", + "SharkPool/Camera@_New Camera name:": "Новое имя Камеры:", + "SharkPool/Camera@_Remove Camera": "Удалить Камеру", + "SharkPool/Camera@_Remove Camera named:": "Имя Удалённой Камеры:", "SharkPool/Camera@_Stage": "Сцена", + "SharkPool/Camera@_Utility": "Утилита", + "SharkPool/Camera@_[CAMERA] camera direction": "направление камеры [CAMERA]", + "SharkPool/Camera@_[CAMERA] camera x": "x камеры [CAMERA]", + "SharkPool/Camera@_[CAMERA] camera y": "y камеры [CAMERA]", + "SharkPool/Camera@_[CAMERA] camera zoom": "приближение камеры [CAMERA]", + "SharkPool/Camera@_all objects": "все объекты", "SharkPool/Camera@_background color": "цвет заднего фона", + "SharkPool/Camera@_bind": "привязка", + "SharkPool/Camera@_bind [TARGET] to camera [CAMERA]": "привязать [TARGET] на камеру [CAMERA]", + "SharkPool/Camera@_camera of [TARGET]": "камера [TARGET]", + "SharkPool/Camera@_change [CAMERA] camera x by [NUM]": "изменить x камеры [CAMERA] на [NUM]", + "SharkPool/Camera@_change [CAMERA] camera y by [NUM]": "изменить y камеры [CAMERA] на [NUM]", + "SharkPool/Camera@_change [CAMERA] camera zoom by [NUM]": "изменить приближение камеры [CAMERA] на [NUM]", "SharkPool/Camera@_default": "по умолчанию", + "SharkPool/Camera@_mouse x in camera [CAMERA]": "x курсора мыши в камере [CAMERA]", + "SharkPool/Camera@_mouse y in camera [CAMERA]": "y курсора мыши в камере [CAMERA]", + "SharkPool/Camera@_move [CAMERA] camera [NUM] steps": "идти камерой [CAMERA] на [NUM] шагов", + "SharkPool/Camera@_move [CAMERA] camera to [TARGET]": "переместить камеру [CAMERA] на [TARGET]", "SharkPool/Camera@_myself": "самого себя", + "SharkPool/Camera@_pen layer": "слой ручки", + "SharkPool/Camera@_point [CAMERA] camera towards [TARGET]": "указать камеру [CAMERA] на [TARGET]", + "SharkPool/Camera@_rendered x position of [TARGET]": "визуализированная позиция x из [TARGET]", + "SharkPool/Camera@_rendered y position of [TARGET]": "визуализированная позиция y из [TARGET]", + "SharkPool/Camera@_set [CAMERA] camera direction to [NUM]": "задать направление камеры [CAMERA] на [NUM]", + "SharkPool/Camera@_set [CAMERA] camera to x: [X] y: [Y]": "задать камеру [CAMERA] на x: [X] y: [Y]", + "SharkPool/Camera@_set [CAMERA] camera x to [NUM]": "задать x камеры [CAMERA] на [NUM]", + "SharkPool/Camera@_set [CAMERA] camera y to [NUM]": "задать y камеры [CAMERA] на [NUM]", + "SharkPool/Camera@_set [CAMERA] camera zoom to [NUM]%": "задать приближение камеры [CAMERA] на [NUM]%", + "SharkPool/Camera@_set background color to [COLOR]": "задать цвет заднего фона на [COLOR]", + "SharkPool/Camera@_turn [CAMERA] camera [IMG] [NUM] degrees": "повернуть камеру [CAMERA] [IMG] на [NUM] градусов", + "SharkPool/Camera@_unbind": "отвязать", + "SharkPool/Camera@_unbind [TARGET] from camera [CAMERA]": "отвязать [TARGET] с камеры [CAMERA]", + "SharkPool/Camera@_video layer": "слой видео", "SharkPool/Font-Manager@_Font Manager": "Менеджер Шрифтов", "SharkPool/Font-Manager@_[ADDED] fonts": "[ADDED] шрифты", "SharkPool/Font-Manager@_[DATA] of font [NAME]": "[DATA] шрифта [NAME]", @@ -8811,6 +8859,7 @@ "Skyhigh173/json@_General Utils": "Главные Утилиты", "Skyhigh173/json@_Lists": "Списки", "Skyhigh173/json@_Object": "Объект", + "Skyhigh173/json@_[analysis] of array [list]": "[analysis] массива [list]", "Skyhigh173/json@_[json] contains key [key]?": "[json] содержит ключ [key]?", "Skyhigh173/json@_[json] contains value [value]?": "[json] содержит значение [value]?", "Skyhigh173/json@_add [item] to array [json]": "добавить [item] к массиву [json]", @@ -8818,6 +8867,7 @@ "Skyhigh173/json@_array concat [json] [json2]": "соединить массив [json] и [json2]", "Skyhigh173/json@_array from text [json]": "массив из текста [json]", "Skyhigh173/json@_ascending": "нарастающей", + "Skyhigh173/json@_average": "среднее", "Skyhigh173/json@_create array by [text] with delimiter [d]": "создать массив из [text] с разделителем [d]", "Skyhigh173/json@_datas": "данные", "Skyhigh173/json@_delete [item] in [json]": "удалить [item] из [json]", @@ -8837,6 +8887,10 @@ "Skyhigh173/json@_keys": "ключи", "Skyhigh173/json@_length of array [json]": "длина массива [json]", "Skyhigh173/json@_length of json [json]": "длина json [json]", + "Skyhigh173/json@_maximum value": "максимальное значение", + "Skyhigh173/json@_median": "медиан", + "Skyhigh173/json@_minimum value": "минимальное значение", + "Skyhigh173/json@_mode": "режим", "Skyhigh173/json@_new [json]": "новый [json]", "Skyhigh173/json@_replace item [pos] of [json] with [item]": "заменить число [pos] матрицы [json] на [item]", "Skyhigh173/json@_reverse array [json]": "обернуть массив [json]", @@ -8845,8 +8899,10 @@ "Skyhigh173/json@_set length of array [json] to [len]": "задать длину массива [json] на [len]", "Skyhigh173/json@_set list [list] to [json]": "задать список [list] в [json]", "Skyhigh173/json@_sort array [list] in [order] order": "сортировать массив [list] в порядке [order]", + "Skyhigh173/json@_sum": "сумма", "Skyhigh173/json@_value of [item] in [json]": "значение [item] в [json]", "Skyhigh173/json@_values": "значения", + "Skyhigh173/json@_variance": "расхождение", "TheShovel/CanvasEffects@_Canvas Effects": "Canvas Эффекты", "TheShovel/CanvasEffects@_background": "задний фон", "TheShovel/CanvasEffects@_blur": "блюр", @@ -9934,6 +9990,8 @@ "true-fantom/math@_is safe number [A]?": "[A] безопасное число?", "true-fantom/math@_log of [A] with base [B]": "логарифм [A] с основанием [B]", "true-fantom/math@_map [A] from range [m1] - [M1] to range [m2] - [M2]": "карта [A] из расстояния [m1] - [M1] до расстояния [m2] - [M2]", + "true-fantom/math@_sign of [A]": "знак [A]", + "true-fantom/math@_true [OPERATOR] [NUM]": "правда [OPERATOR] [NUM] ", "true-fantom/math@_trunc of [A]": "усечение [A]", "true-fantom/math@_trunc of [A] with [B] digits after dot": "усечение [A] с [B] цифрами после точки", "true-fantom/network@_(1) text": "(1) текст",