Spaces:
Running
Running
| ; | |
| /* | |
| * 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(); | |