From ac6f021e6da0eba9113c69f23a4e651469164e27 Mon Sep 17 00:00:00 2001 From: lselden <2904993+lselden@users.noreply.github.com> Date: Wed, 5 Mar 2025 12:12:00 -0500 Subject: [PATCH 01/61] Add midi extension Added midi extension for input/output of midi using the WebMidi API. Still pending documentation and peer-review --- extensions/midi/midi.js | 1884 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1884 insertions(+) create mode 100644 extensions/midi/midi.js diff --git a/extensions/midi/midi.js b/extensions/midi/midi.js new file mode 100644 index 0000000000..dbec4a46c6 --- /dev/null +++ b/extensions/midi/midi.js @@ -0,0 +1,1884 @@ +// Name: MIDI +// ID: extmidi +// Description: An extension to use the WebMidi API for midi input/output. +// License: MPL-2.0 + +(function (Scratch) { + "use strict"; + + if (!Scratch.extensions.unsandboxed) { + throw new Error("Midi must be run unsandboxed"); + } + + const EXT_ID = "extmidi"; + + /** + * 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 {keyof typeof eventMapping} EventType + * @typedef {object} MidiEvent + * @property {EventType | 'rest'} type + * @property {number} [value1] + * @property {number} [value2] + * @property {number} [channel] + * @property {number} [device] + * @property {number} [time] + * @property {number} [pitch] + * @property {number} [velocity] + * @property {number} [cc] + * @property {number} [value] + * @property {number} [pos] + * @property {number} [dur] + * @property {string} [_str] + * + * + * @typedef {object} FormatOptions + * @property {number} [tempo] + * @property {boolean} [useFlats] + * @property {boolean} [noMinify] + * @property {'omit' | 'timestamp' | 'absolute'} [timestampFormat] + * @property {number} [startTimestamp] + * @property {boolean} [fixedWidth] + * @property {boolean} [useHex] + * @property {number} [defaultOctave] + */ + + /** + * 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. noteOn 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, { useFlats = false, fixedWidth = false } = {}) { + if (!isFinite(midi)) return ""; + let chroma = (useFlats ? 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 + * @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, defaultValue, opts) { + if (!text) return defaultValue; + text = text.trim(); + const useHex = opts?.useHex ?? /[a-f]/.test(text); + const radix = useHex ? 16 : 10; + const val = parseInt(text, radix); + return isNaN(val) ? defaultValue : val; + } + function formatHex(value, pad = 2) { + return Math.round(value).toString(16).padStart(pad, "0"); + } + + const eventMapping = { + ["noteOn" /* Note */]: { shorthand: "note", command: 144, description: "Note-on", param1: "pitch", param2: "velocity", defaults: [60, 96] }, + ["noteOff" /* NoteOff */]: { shorthand: "off", command: 128, description: "Note-off", param1: "pitch", param2: "velocity", defaults: [60, 0] }, + ["cc" /* CC */]: { shorthand: "cc", command: 176, description: "Continuous controller", param1: "cc", param2: "value", defaults: [0, 0] }, + ["polyTouch" /* NoteAftertouch */]: { shorthand: "touch", command: 160, description: "Aftertouch", param1: "pitch", param2: "value", defaults: [60, 64] }, + ["programChange" /* ProgramChange */]: { shorthand: "program", command: 192, description: "Patch change", param1: "value" }, + ["pitchBend" /* PitchBend */]: { shorthand: "bend", command: 224, description: "Pitch bend", highResParam: "value" }, + ["channelPressure" /* ChannelPressure */]: { shorthand: "pressure", command: 208, description: "Channel Pressure", param1: "value" }, + ["songPosition" /* SongPosition */]: { shorthand: "songpos", command: 242, description: "Song Position Pointer (Sys Common)", highResParam: "value" }, + ["songSelect" /* SongSelect */]: { shorthand: "songsel", command: 243, description: "Song Select (Sys Common)", param1: "value" }, + ["clock" /* Clock */]: { shorthand: "clock", command: 248, description: "Timing Clock (Sys Realtime)" }, + ["start" /* Start */]: { shorthand: "start", command: 250, description: "Start (Sys Realtime)" }, + ["continue" /* Continue */]: { shorthand: "continue", command: 251, description: "Continue (Sys Realtime)" }, + ["stop" /* Stop */]: { shorthand: "stop", command: 252, description: "Stop (Sys Realtime)" }, + ["activeSensing" /* ActiveSensing */]: { shorthand: "ping", command: 254, description: "Active Sensing (Sys Realtime)" }, + ["reset" /* Reset */]: { shorthand: "reset", command: 255, description: "System Reset (Sys Realtime)" } + }; + /** @type {Map} */ + // @ts-ignore + const commandLookup = new Map(Object.entries(eventMapping).map(([key, { command }]) => [command, key])); + /** @type {Map} */ + // @ts-ignore + 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])); + /** + * @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)}`; + } + /** @type {Record string>} */ + const formatters = { + noteOn({ value1: note, value2: velocity = eventMapping.noteOn.defaults[1] }, opts = {}) { + return formatNoteType(opts?.noMinify ? shorthands.noteOn : undefined, note, velocity, opts); + }, + noteOff({ value1: note }, opts = {}) { + return formatNoteType(opts?.noMinify ? shorthands.noteOff : undefined, 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 = 0, value2 = 64 }, opts) { + return `${shorthands.pitchBend} ${formatHighResValue(value1, value2, opts)}`; + }, + songPosition({ value1 = 0, value2 = 0 }, 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 + }; + + const PREFIX_CHANNEL = "ch"; + const PREFIX_DEVICE = "dev"; + const PREFIX_WHEN = "@"; + const PREFIX_POS = 'pos='; + const PREFIX_DURATION = 'beats='; + /** Used when creating lists - treat ~ as empty no value */ + const REST_LITERAL = '~'; + + // FUTURE - convert everything after data1/data2 to an iterator to parse tokens so they don't need to be in specific order + // FUTURE - support unicode note durations https://en.wikipedia.org/wiki/Musical_Symbols_(Unicode_block) + const midiStringRegex = /^\s*(?[a-zA-Z]{2,})?\s*(?[A-G][#b♯♭_]*-?\d?)?\s*(?\b-?[0-9a-f]{1,5}\b)?\s*(?\b[0-9a-f]{1,3}\b)?\s*(ch=?(?[0-9a-f]{1,2}))?\s*(dev=?(?[0-9a-f]))?\s*((@|time=)\s*(?