Skip to content

Commit af541ff

Browse files
authored
Merge pull request #487 from FloppyDisk-OSC/main
Add MIDI Controller
2 parents 5d9e288 + 832ab78 commit af541ff

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

src/lib/extensions.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,4 +603,11 @@ export default [
603603
creator: "ElectricFuzzball_PM"
604604
//:brah:
605605
},
606+
{
607+
name: "MIDI Controller",
608+
description: "Use a MIDI keyboard to interact with projects!",
609+
code: "electricfuzzball_pm/MIDI.js",
610+
banner: "electricfuzzball_pm/MIDI.svg",
611+
creator: "ElectricFuzzball_PM"
612+
},
606613
];
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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);

static/images/electricfuzzball_pm/MIDI.svg

Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)