"use strict"; /* * Copyright (c) 2013-2025 Vanessa Freudenberg * * 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. */ function MIDIPlugin() { "use strict"; const MIDI = midiParameterConstants(); return { debug: false, vmProxy: null, vm: null, prims: null, timeOffset: 0, midi: null, // WebMIDI access or false if not supported midiPromise: null, ports: new Map(), // indexed by Squeak port number getModuleName() { return 'MIDIPlugin (SqueakJS)'; }, setInterpreter(vmProxy) { this.vmProxy = vmProxy; this.vm = vmProxy.vm; this.prims = vmProxy.vm.primHandler; return true; }, initialiseModule() { this.debug = this.vm.options.debugMIDI; if (!navigator.requestMIDIAccess) { console.log('MIDIPlugin: WebMIDI not supported'); this.vmProxy.success(false); return; } if (!this.midiPromise) { this.midiPromise = navigator.requestMIDIAccess({ software: true, // because why not sysex: false, // if you change this, tweak the running status handling }) .then(access => { this.midi = access; this.initMIDI(access); }) .catch(err => { console.error('MIDIPlugin: ' + err); this.midi = false; }); } if (performance.timeOrigin) this.timeOffset = performance.timeOrigin - this.vm.startupTime; }, initMIDI(access) { const allPorts = [...access.inputs.values(), ...access.outputs.values()]; for (const port of allPorts) this.portChanged(port); access.onstatechange = (event) => { const port = event.port; let { name, manufacturer, state } = port; if (manufacturer && !name.includes(manufacturer)) name += ` (${manufacturer})`; const sqPort = this.portChanged(port); const isNew = !port.sqPort; if (isNew) port.sqPort = sqPort; if (isNew || state === 'disconnected' || this.debug) { console.log(`MIDIPlugin: ${name} ${state} (port ${sqPort.handle} ${port.type} ${port.connection})`); } }; console.log(`MIDIPlugin: WebMIDI initialized (ports: ${this.ports.size})`); for (const [portNumber, port] of this.ports) { const dir = port.dir === 3 ? 'in+out' : port.dir === 2 ? 'out' : 'in'; const names = []; if (port.input) names.push(port.input.name); if (port.output) names.push(port.output.name); console.log(`MIDIPlugin: port ${portNumber} ${dir} (${names.join(', ')})`); } }, portChanged(port) { // Squeak likes combined input/output ports so we create sqPorts with input+output here let { name, manufacturer } = port; // strip input / output designation name = name.replace(/(\b(in|out)(put)?\b)/i, '').replace(/(\(\)|\[\])/, '').replace(/ /, " ").trim(); if (manufacturer && !name.includes(manufacturer)) name += ` (${manufacturer})`; // find existing port or create new one let sqPort; for (const existingPort of this.ports.values()) { if (existingPort.name === name) { sqPort = existingPort; break; } } if (!sqPort) { const handle = this.ports.size; sqPort = { handle, name, dir: 0, input: null, output: null, runningStatus: 0, receivedMessages: [], }; this.ports.set(handle, sqPort); } // dir: 1=input, 2=output, 3=input+output if (port.state === "connected") { sqPort[port.type] = port; sqPort.dir |= port.type === 'input' ? 1 : 2; } else { sqPort[port.type] = null; sqPort.dir &= port.type === 'input' ? ~1 : ~2; } return sqPort; }, primitiveMIDIGetPortCount(argCount) { // we rely on this primitive to be called first // so the other primitives can be synchronous const returnCount = () => this.vm.popNandPush(argCount + 1, this.ports.size); if (this.midi === null) { const unfreeze = this.vm.freeze(); this.midiPromise .then(returnCount) .catch(err => { console.error('MIDIPlugin: ' + err); returnCount(); }) .finally(unfreeze); } else { returnCount(); } return true; }, primitiveMIDIGetPortName(argCount) { if (!this.midi) return false; const portNumber = this.prims.stackInteger(0); const port = this.ports.get(portNumber); if (!port) return false; let name = port.name; if (port.dir === 0) name += ' [disconnected]'; return this.prims.popNandPushIfOK(argCount + 1, this.prims.makeStString(name)); }, primitiveMIDIGetPortDirectionality(argCount) { if (!this.midi) return false; const portNumber = this.prims.stackInteger(0); const port = this.ports.get(portNumber); if (!port) return false; return this.prims.popNandPushIfOK(argCount + 1, port.dir); }, primitiveMIDIGetClock(argCount) { if (!this.midi) return false; const clock = this.prims.millisecondClockValue(); return this.prims.popNandPushIfOK(argCount + 1, clock); }, primitiveMIDIParameterGetOrSet(argCount) { if (!this.midi) return false; const parameter = this.prims.stackInteger(argCount - 1); // const newValue = argCount > 1 ? this.prims.stackInteger(0) : null; let value; // mostly untested, because I found no Squeak app that actually uses these switch (parameter) { case MIDI.Installed: value = 1; break case MIDI.Version: value = 1; break; case MIDI.HasBuffer: case MIDI.HasDurs: case MIDI.CanSetClock: case MIDI.CanUseSemaphore: case MIDI.EchoOn: case MIDI.UseControllerCache: case MIDI.EventsAvailable: case MIDI.FlushDriver: value = 0; break; case MIDI.ClockTicksPerSec: value = 1000; break; case MIDI.HasInputClock: value = 1; break; default: return false; } return this.prims.popNandPushIfOK(argCount + 1, value); }, primitiveMIDIOpenPort(argCount) { const portNumber = this.prims.stackInteger(2); // const readSemaIndex = this.prims.stackInteger(1); // ignored // const interfaceClockRate = this.prims.stackInteger(0); // ignored let port; const checkPort = () => { port = this.ports.get(portNumber); if (!port) console.error(`MIDIPlugin: invalid port ${portNumber}`); else if (!port.dir) { console.error(`MIDIPlugin: port ${portNumber} ${port.name} is disconnected`); port = null; } }; const openPort = unfreeze => { const promises = []; // wait for MIDI initialization first if (port.input) if (port.input.connection === "closed") promises.push(port.input.open()); else console.warn(`MIDIPlugin: input port ${portNumber} is ${port.input.connection}`); if (port.output) if (port.output.connection === "closed") promises.push(port.output.open()); else console.warn(`MIDIPlugin: output port ${portNumber} is ${port.output.connection}`); port.runningStatus = 0; port.receivedMessages = []; Promise.all(promises) .then(() => { if (port.input) port.input.onmidimessage = event => { const time = Math.round(event.timeStamp + this.timeOffset); const bytes = new Uint8Array(event.data); port.receivedMessages.push({time, bytes}); if (this.debug) console.log('MIDIPlugin: received', time, [...bytes]); }; }) .catch(err => console.error('MIDIPlugin: ' + err)) .finally(unfreeze); }; // if already initialized, report failure immediately if (this.midi) { checkPort(); if (!port) return false; } // otherwise, we wait for initialization const unfreeze = this.vm.freeze(); this.midiPromise .then(() => { if (!port) checkPort(); if (port) openPort(unfreeze); else unfreeze(); }); return this.prims.popNIfOK(argCount); }, primitiveMIDIClosePort(argCount) { // ok to close even if not initialized if (this.midi) { const portNumber = this.prims.stackInteger(0); const port = this.ports.get(portNumber); if (!port) return false; const promises = []; if (port.input && port.input.connection === 'open') { promises.push(port.input.close()); port.input.onmidimessage = null; port.receivedMessages.length = 0; } if (port.output && port.output.connection === 'open') { promises.push(port.output.close()); } if (promises.length) { const unfreeze = this.vm.freeze(); Promise.all(promises) .catch(err => console.error('MIDIPlugin: ' + err)) .finally(unfreeze); } } return this.prims.popNIfOK(argCount); }, primitiveMIDIWrite(argCount) { if (!this.midi) return false; const portNumber = this.prims.stackInteger(2); let data = this.prims.stackNonInteger(1).bytes; const timestamp = this.prims.stackInteger(0); const port = this.ports.get(portNumber); if (!port || !port.output || !data) return false; if (port.output.connection !== 'open') { console.error('MIDIPlugin: primitiveMIDIWrite error (port not open)'); return this.prims.popNandPushIfOK(argCount + 1, 0); } // this could be simple if it were not for the running status // WebMIDI insists the first byte is a status byte // so we need to keep track of it, and prepend it if necessary if (data[0] < 0x80) { if (port.runningStatus === 0) { console.error('MIDIPlugin: no running status byte'); return false; } const newData = new Uint8Array(data.length + 1); newData[0] = port.runningStatus; newData.set(data, 1); data = newData; } try { if (this.debug) console.log('MIDIPlugin: send', [...data], timestamp); // send or schedule data if (timestamp === 0) port.output.send(data); else port.output.send(data, timestamp); // find last status byte in data, but ignore real-time messages (0xF8-0xFF) // system common messages (0xF0-0xF7) reset the running status for (let i = data.length - 1; i >= 0; i--) { if (data[i] >= 0x80 && data[i] <= 0xF7) { port.runningStatus = data[i] < 0xF0 ? data[i] : 0; break; } } } catch (err) { console.error('MIDIPlugin: ' + err); return false; } return this.prims.popNandPushIfOK(argCount + 1, data.length); }, primitiveMIDIRead(argCount) { if (!this.midi) return false; const portNumber = this.prims.stackInteger(1); const data = this.prims.stackNonInteger(0).bytes; const port = this.ports.get(portNumber); if (!port || !port.input || port.input.connection !== 'open') return false; let received = 0; const event = port.receivedMessages.shift(); if (event) { let { time, bytes } = event; data[0] = (time >> 24) & 0xFF; data[1] = (time >> 16) & 0xFF; data[2] = (time >> 8) & 0xFF; data[3] = time & 0xFF; data.set(bytes, 4); received = bytes.length + 4; if (this.debug) console.log('MIDIPlugin: read', received, [...data.subarray(0, received)]); } return this.prims.popNandPushIfOK(argCount + 1, received); }, }; } function midiParameterConstants() { // MIDI parameter key constants // see primitiveMIDIParameterGetOrSet() for SqueakJS values return { Installed: 1, // Read-only. Return 1 if a MIDI driver is installed, 0 if not. // On OMS-based MIDI drivers, this returns 1 only if the OMS // system is properly installed and configured. Version: 2, // Read-only. Return the integer version number of this MIDI driver. // The version numbering sequence is relative to a particular driver. // That is, version 3 of the Macintosh MIDI driver is not necessarily // related to version 3 of the Win95 MIDI driver. HasBuffer: 3, // Read-only. Return 1 if this MIDI driver has a time-stamped output // buffer, 0 otherwise. Such a buffer allows the client to schedule // MIDI output packets to be sent later. This can allow more precise // timing, since the driver uses timer interrupts to send the data // at the right time even if the processor is in the midst of a // long-running Squeak primitive or is running some other application // or system task. HasDurs: 4, // Read-only. Return 1 if this MIDI driver supports an extended // primitive for note-playing that includes the note duration and // schedules both the note-on and the note-off messages in the // driver. Otherwise, return 0. CanSetClock: 5, // Read-only. Return 1 if this MIDI driver's clock can be set // via an extended primitive, 0 if not. CanUseSemaphore: 6, // Read-only. Return 1 if this MIDI driver can signal a semaphore // when MIDI input arrives. Otherwise, return 0. If this driver // supports controller caching and it is enabled, then incoming // controller messages will not signal the semaphore. EchoOn: 7, // Read-write. If this flag is set to a non-zero value, and if // the driver supports echoing, then incoming MIDI events will // be echoed immediately. If this driver does not support echoing, // then queries of this parameter will always return 0 and // attempts to change its value will do nothing. UseControllerCache: 8, // Read-write. If this flag is set to a non-zero value, and if // the driver supports a controller cache, then the driver will // maintain a cache of the latest value seen for each MIDI controller, // and control update messages will be filtered out of the incoming // MIDI stream. An extended MIDI primitive allows the client to // poll the driver for the current value of each controller. If // this driver does not support a controller cache, then queries // of this parameter will always return 0 and attempts to change // its value will do nothing. EventsAvailable: 9, // Read-only. Return the number of MIDI packets in the input queue. FlushDriver: 10, // Write-only. Setting this parameter to any value forces the driver // to flush its I/0 buffer, discarding all unprocessed data. Reading // this parameter returns 0. Setting this parameter will do nothing // if the driver does not support buffer flushing. ClockTicksPerSec: 11, // Read-only. Return the MIDI clock rate in ticks per second. HasInputClock: 12, // Read-only. Return 1 if this MIDI driver timestamps incoming // MIDI data with the current value of the MIDI clock, 0 otherwise. // If the driver does not support such timestamping, then the // client must read input data frequently and provide its own // timestamping. }; } function registerMIDIPlugin() { if (typeof Squeak === "object" && Squeak.registerExternalModule) { Squeak.registerExternalModule('MIDIPlugin', MIDIPlugin()); } else self.setTimeout(registerMIDIPlugin, 100); }; registerMIDIPlugin();