|
| 1 | +// jwArray placeholder |
| 2 | +let jwArray = { |
| 3 | + Type: class {}, |
| 4 | + Block: {}, |
| 5 | + Argument: {} |
| 6 | +}; |
| 7 | + |
| 8 | +(function (Scratch) { |
| 9 | + 'use strict'; |
| 10 | + |
| 11 | + class MIDIExtension { |
| 12 | + constructor() { |
| 13 | + // Inject jwArray |
| 14 | + if (!Scratch.vm.jwArray) Scratch.vm.extensionManager.loadExtensionIdSync('jwArray'); |
| 15 | + jwArray = Scratch.vm.jwArray; |
| 16 | + |
| 17 | + this.midiAccess = null; |
| 18 | + this.inputs = []; |
| 19 | + this.listening = false; |
| 20 | + |
| 21 | + this.noteMap = { |
| 22 | + 60: 'kick', |
| 23 | + 62: 'snare', |
| 24 | + 64: 'hihat', |
| 25 | + 65: 'clap', |
| 26 | + 36: 'pad1', |
| 27 | + 37: 'pad2', |
| 28 | + 38: 'pad3', |
| 29 | + 39: 'pad4', |
| 30 | + 40: 'pad5', |
| 31 | + 41: 'pad6', |
| 32 | + 42: 'pad7', |
| 33 | + 43: 'pad8' |
| 34 | + }; |
| 35 | + |
| 36 | + this._currentNote = 0; |
| 37 | + this._currentVelocity = 0; |
| 38 | + this._currentPad = 0; |
| 39 | + this._currentPadVelocity = 0; |
| 40 | + |
| 41 | + this._notesPressed = new Set(); |
| 42 | + |
| 43 | + this.visualizerEl = null; |
| 44 | + } |
| 45 | + |
| 46 | + getInfo() { |
| 47 | + return { |
| 48 | + id: 'midi', |
| 49 | + name: 'MIDI', |
| 50 | + color1: '#960000', |
| 51 | + iconURI: 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIyOC41OTE4MyIgaGVpZ2h0PSIyNS4zODk0NCIgdmlld0JveD0iMCwwLDI4LjU5MTgzLDI1LjM4OTQ0Ij48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjI1LjcwNDA4LC0xNjcuMzA1MjgpIj48ZyBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCI+PHBhdGggZD0iTTIyNi4yMDQwOCwxNzQuMzk1MDl2LTYuNTg5ODFoMjcuNTkxODN2Ni41ODk4MXoiIGZpbGw9Im5vbmUiLz48cGF0aCBkPSJNMjI3LjY0NDY0LDE5Mi4xOTQ3MmMtMC43OTU2LDAgLTEuNDQwNTUsLTAuODE3NTggLTEuNDQwNTUsLTEuODI2MTF2LTE1Ljk3MzUzaDYuODk3OTZ2MTUuOTczNTNjMCwxLjAwODUzIC0wLjY0NDk2LDEuODI2MTEgLTEuNDQwNTUsMS44MjYxMXoiIGZpbGw9Im5vbmUiLz48cGF0aCBkPSJNMjM0LjU0MjYsMTkyLjE5NDcyYy0wLjc5NTYsMCAtMS40NDA1NSwtMC44MTc1OCAtMS40NDA1NSwtMS44MjYxMXYtMTUuOTczNTNoNi44OTc5NnYxNS45NzM1M2MwLDEuMDA4NTMgLTAuNjQ0OTYsMS44MjYxMSAtMS40NDA1NSwxLjgyNjExeiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0yNDEuNDQwNTUsMTkyLjE5NDcyYy0wLjc5NTYsMCAtMS40NDA1NSwtMC44MTc1OCAtMS40NDA1NSwtMS44MjYxMXYtMTUuOTczNTNoNi44OTc5NnYxNS45NzM1M2MwLDEuMDA4NTMgLTAuNjQ0OTYsMS44MjYxMSAtMS40NDA1NSwxLjgyNjExeiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0yNDguMzM4NTEsMTkyLjE5NDcyYy0wLjc5NTYsMCAtMS40NDA1NiwtMC44MTc1OCAtMS40NDA1NiwtMS44MjYxMXYtMTUuOTczNTNoNi44OTc5NnYxNS45NzM1M2MwLDEuMDA4NTMgLTAuNjQ0OTYsMS44MjYxMSAtMS40NDA1NSwxLjgyNjExeiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0yMzEuMDgxMTgsMTgzLjU3NDYydi05LjE3OTUzaDQuMDQxNzN2OS4xNzk1M3oiIGZpbGw9IiNmZmZmZmYiLz48cGF0aCBkPSJNMjQ0Ljg3NzA5LDE4My41NzQ2MnYtOS4xNzk1NGg0LjA0MTczdjkuMTc5NTR6IiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTIzNy45NzkxMywxODMuNTc0NjJ2LTkuMTc5NTRoNC4wNDE3M3Y5LjE3OTU0eiIgZmlsbD0iI2ZmZmZmZiIvPjwvZz48L2c+PC9zdmc+PCEtLXJvdGF0aW9uQ2VudGVyOjE0LjI5NTkxNjcyMDYzODc1ODoxMi42OTQ3MjIyMTgzNDU1MDQtLT4=', |
| 52 | + blocks: [ |
| 53 | + { opcode: 'startListening', blockType: Scratch.BlockType.COMMAND, text: 'start MIDI listener' }, |
| 54 | + { opcode: 'stopListening', blockType: Scratch.BlockType.COMMAND, text: 'stop MIDI listener' }, |
| 55 | + { |
| 56 | + opcode: 'setNote', |
| 57 | + blockType: Scratch.BlockType.COMMAND, |
| 58 | + text: 'map note [NOTE] to event [EVENT]', |
| 59 | + arguments: { |
| 60 | + NOTE: { type: Scratch.ArgumentType.NUMBER, defaultValue: 60 }, |
| 61 | + EVENT: { type: Scratch.ArgumentType.STRING, defaultValue: 'kick' } |
| 62 | + } |
| 63 | + }, |
| 64 | + { opcode: 'currentNote', blockType: Scratch.BlockType.REPORTER, text: 'current note number' }, |
| 65 | + { opcode: 'currentVelocity', blockType: Scratch.BlockType.REPORTER, text: 'current note velocity' }, |
| 66 | + { opcode: 'currentPad', blockType: Scratch.BlockType.REPORTER, text: 'current pad ID' }, |
| 67 | + { opcode: 'currentPadVelocity', blockType: Scratch.BlockType.REPORTER, text: 'current pad velocity' }, |
| 68 | + { |
| 69 | + opcode: 'notesPressed', |
| 70 | + blockType: Scratch.BlockType.REPORTER, |
| 71 | + text: 'notes currently pressed', |
| 72 | + ...jwArray.Block |
| 73 | + }, |
| 74 | + { opcode: 'showVisualizer', blockType: Scratch.BlockType.COMMAND, text: 'show visualizer' }, |
| 75 | + { opcode: 'hideVisualizer', blockType: Scratch.BlockType.COMMAND, text: 'hide visualizer' } |
| 76 | + ] |
| 77 | + }; |
| 78 | + } |
| 79 | + |
| 80 | + async startListening() { |
| 81 | + if (this.listening) return; |
| 82 | + this.listening = true; |
| 83 | + |
| 84 | + if (!navigator.requestMIDIAccess) { |
| 85 | + console.log('MIDI not supported in this browser!'); |
| 86 | + return; |
| 87 | + } |
| 88 | + |
| 89 | + this.midiAccess = await navigator.requestMIDIAccess(); |
| 90 | + this.inputs = Array.from(this.midiAccess.inputs.values()); |
| 91 | + this.inputs.forEach(input => input.onmidimessage = this.handleMIDIMessage.bind(this)); |
| 92 | + console.log('MIDI listener started!'); |
| 93 | + } |
| 94 | + |
| 95 | + stopListening() { |
| 96 | + this.listening = false; |
| 97 | + if (this.inputs.length > 0) { |
| 98 | + this.inputs.forEach(input => input.onmidimessage = null); |
| 99 | + } |
| 100 | + this._notesPressed.clear(); |
| 101 | + this.updateVisualizer(); |
| 102 | + console.log('MIDI listener stopped!'); |
| 103 | + } |
| 104 | + |
| 105 | + setNote(args) { |
| 106 | + const note = args.NOTE; |
| 107 | + const event = args.EVENT; |
| 108 | + this.noteMap[note] = event; |
| 109 | + console.log(`Mapped note ${note} → event "${event}"`); |
| 110 | + } |
| 111 | + |
| 112 | + handleMIDIMessage(event) { |
| 113 | + if (!this.listening) return; |
| 114 | + |
| 115 | + const [status, noteValue, velocityValue] = event.data; |
| 116 | + const isNoteOn = (status & 0xF0) === 0x90 && velocityValue > 0; |
| 117 | + const isNoteOff = ((status & 0xF0) === 0x80) || ((status & 0xF0) === 0x90 && velocityValue === 0); |
| 118 | + |
| 119 | + if (isNoteOn) { |
| 120 | + const isPad = (noteValue >= 36 && noteValue <= 51); |
| 121 | + if (isPad) { |
| 122 | + this._currentPad = noteValue; |
| 123 | + this._currentPadVelocity = velocityValue; |
| 124 | + } else { |
| 125 | + this._currentNote = noteValue; |
| 126 | + this._currentVelocity = velocityValue; |
| 127 | + } |
| 128 | + |
| 129 | + this._notesPressed.add(noteValue); |
| 130 | + |
| 131 | + const mappedEvent = this.noteMap[noteValue]; |
| 132 | + if (mappedEvent) { |
| 133 | + Scratch.vm.runtime.emit('MIDI_NOTE', { |
| 134 | + note: noteValue, |
| 135 | + velocity: velocityValue, |
| 136 | + event: mappedEvent, |
| 137 | + isPad: isPad |
| 138 | + }); |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + if (isNoteOff) { |
| 143 | + this._notesPressed.delete(noteValue); |
| 144 | + } |
| 145 | + |
| 146 | + this.updateVisualizer(); |
| 147 | + } |
| 148 | + |
| 149 | + noteNumberToName(number) { |
| 150 | + const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; |
| 151 | + const octave = Math.floor(number / 12) - 1; |
| 152 | + const name = names[number % 12]; |
| 153 | + return `${name}${octave}`; |
| 154 | + } |
| 155 | + |
| 156 | + showVisualizer() { |
| 157 | + if (this.visualizerEl) return; |
| 158 | + this.visualizerEl = document.createElement('div'); |
| 159 | + this.visualizerEl.style.position = 'fixed'; |
| 160 | + this.visualizerEl.style.top = '50px'; |
| 161 | + this.visualizerEl.style.left = '50px'; |
| 162 | + this.visualizerEl.style.background = '#222'; |
| 163 | + this.visualizerEl.style.color = '#fff'; |
| 164 | + this.visualizerEl.style.padding = '10px'; |
| 165 | + this.visualizerEl.style.borderRadius = '8px'; |
| 166 | + this.visualizerEl.style.fontFamily = 'monospace'; |
| 167 | + this.visualizerEl.style.zIndex = 9999; |
| 168 | + this.visualizerEl.style.cursor = 'move'; |
| 169 | + this.visualizerEl.innerText = 'Notes: []'; |
| 170 | + document.body.appendChild(this.visualizerEl); |
| 171 | + |
| 172 | + let isDragging = false; |
| 173 | + let offsetX = 0; |
| 174 | + let offsetY = 0; |
| 175 | + |
| 176 | + this.visualizerEl.addEventListener('mousedown', (e) => { |
| 177 | + isDragging = true; |
| 178 | + offsetX = e.clientX - this.visualizerEl.offsetLeft; |
| 179 | + offsetY = e.clientY - this.visualizerEl.offsetTop; |
| 180 | + }); |
| 181 | + |
| 182 | + document.addEventListener('mousemove', (e) => { |
| 183 | + if (isDragging) { |
| 184 | + this.visualizerEl.style.left = (e.clientX - offsetX) + 'px'; |
| 185 | + this.visualizerEl.style.top = (e.clientY - offsetY) + 'px'; |
| 186 | + } |
| 187 | + }); |
| 188 | + |
| 189 | + document.addEventListener('mouseup', () => { |
| 190 | + isDragging = false; |
| 191 | + }); |
| 192 | + |
| 193 | + this.updateVisualizer(); |
| 194 | + } |
| 195 | + |
| 196 | + hideVisualizer() { |
| 197 | + if (this.visualizerEl) { |
| 198 | + document.body.removeChild(this.visualizerEl); |
| 199 | + this.visualizerEl = null; |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + updateVisualizer() { |
| 204 | + if (!this.visualizerEl) return; |
| 205 | + const names = Array.from(this._notesPressed).map(n => this.noteNumberToName(n)); |
| 206 | + this.visualizerEl.innerText = `Notes: [${names.join(', ')}]`; |
| 207 | + } |
| 208 | + |
| 209 | + currentNote() { return this._currentNote; } |
| 210 | + currentVelocity() { return this._currentVelocity; } |
| 211 | + currentPad() { return this._currentPad; } |
| 212 | + currentPadVelocity() { return this._currentPadVelocity; } |
| 213 | + notesPressed() { |
| 214 | + // Return formatted as jwArray type |
| 215 | + return new jwArray.Type(Array.from(this._notesPressed)); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + Scratch.extensions.register(new MIDIExtension()); |
| 220 | +})(Scratch); |
0 commit comments