diff --git a/README.md b/README.md index 5bf11a15..4267129f 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ All commands have a shorthand equivalent to their first two characters, for exam - `midi:1;2` Set Midi output device to `#1`, and input device to `#2`. - `udp:1234;5678` Set UDP output port to `1234`, and input port to `5678`. - `osc:1234` Set OSC output port to `1234`. +- `link` Enables/Disables Ableton Link ## Base36 Table diff --git a/desktop/package-lock.json b/desktop/package-lock.json index e9197df3..5769b99a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -67,6 +67,15 @@ "integrity": "sha512-sz9MF/zk6qVr3pAnM0BSQvYIBK44tS75QC5N+VbWSE4DjCV/pJ+UzCW/F+vVnl7TkOPcuwQureKNtSSwjBTaMg==", "dev": true }, + "abletonlink-addon": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/abletonlink-addon/-/abletonlink-addon-0.2.9.tgz", + "integrity": "sha512-gYsedXCU0edpMBAyGmrMWAKkPwrMgqKT11xKCAixenOWH4X/RiZKC3avVaP94+uQs4J11ujuV7axuIZSMeKp3g==", + "requires": { + "bindings": "^1.5.0", + "node-addon-api": "^2.0.0" + } + }, "asar": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/asar/-/asar-2.1.0.tgz", @@ -101,6 +110,14 @@ "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "binpack": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/binpack/-/binpack-0.1.0.tgz", @@ -523,6 +540,11 @@ "pend": "~1.2.0" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "find-up": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", @@ -914,6 +936,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" + }, "node-osc": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/node-osc/-/node-osc-4.1.8.tgz", diff --git a/desktop/package.json b/desktop/package.json index b6df5847..2c6a2c77 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -27,6 +27,7 @@ "electron-packager": "^14.2.1" }, "dependencies": { + "abletonlink-addon": "^0.2.9", "node-osc": "^4.1.8" }, "standard": { diff --git a/desktop/sources/scripts/client.js b/desktop/sources/scripts/client.js index f1e47062..426ac758 100644 --- a/desktop/sources/scripts/client.js +++ b/desktop/sources/scripts/client.js @@ -11,8 +11,10 @@ /* global Clock */ /* global Theme */ +const AbletonLink = require("abletonlink-addon") + function Client () { - this.version = 176 + this.version = 177 this.library = library this.theme = new Theme(this) @@ -26,6 +28,36 @@ function Client () { this.commander = new Commander(this) this.clock = new Clock(this) + // Ableton Link + this.link = new AbletonLink() + this.numPeers = 0 + + this.link.setTempoCallback((newTempo) => { + console.log('Ableton Link', 'New Tempo', newTempo) + newTempo = this.link.getTempo(true) + if (this.clock.isLinkEnabled() && this.clock.speed.value != newTempo) { + this.clock.setSpeed(newTempo, newTempo, this.link.isPlaying()) + this.update() + }; + }); + + this.link.setStartStopCallback((startStopState) => { + console.log('Ableton Link', startStopState ? 'Start' : 'Stop') + if (startStopState && this.clock.isPaused) { + this.clock.play(false, false, true) + } else if (!startStopState && !this.clock.isPaused) { + this.clock.stop(false) + this.clock.setFrame(0) + this.update() + } + }); + + this.link.setNumPeersCallback((newNumPeers) => { + console.log('Ableton Link', 'NumPeers: ' + newNumPeers) + this.numPeers = newNumPeers + this.update() + }); + // Settings this.scale = window.devicePixelRatio this.grid = { w: 8, h: 8 } @@ -117,6 +149,7 @@ function Client () { this.acels.set('Midi', 'Next Input Device', 'CmdOrCtrl+,', () => { this.clock.setFrame(0); this.io.midi.selectNextInput() }) this.acels.set('Midi', 'Next Output Device', 'CmdOrCtrl+.', () => { this.clock.setFrame(0); this.io.midi.selectNextOutput() }) this.acels.set('Midi', 'Refresh Devices', 'CmdOrCtrl+Shift+M', () => { this.io.midi.refresh() }) + this.acels.set('Midi', 'Toggle Ableton Link', 'CmdOrCtrl+Shift+L', () => { this.toggleLink() }) this.acels.set('Communication', 'Choose OSC Port', 'alt+O', () => { this.commander.start('osc:') }) this.acels.set('Communication', 'Choose UDP Port', 'alt+U', () => { this.commander.start('udp:') }) @@ -160,6 +193,27 @@ function Client () { this.update() } + this.toggleLink = () => { + if (this.clock.isLinkEnabled()) { + console.log('Ableton Link Disabled') + this.link.disable() + this.link.disableStartStopSync() + this.clock.isPuppet = false + this.clock.puppetSource = null + } else if (!this.clock.isLinkEnabled() && !this.clock.isExternalClockActive()){ + console.log('Ableton Link Enabled') + this.link.enable() + this.link.enableStartStopSync() + this.clock.setSpeed(this.link.getTempo(true), this.link.getTempo(true), true) + if (!this.link.isPlaying()) { + this.clock.stop(false) + } + this.clock.isPuppet = true + this.clock.puppetSource = sourceLink + console.log(this.clock.puppetSource) + } + } + this.update = () => { if (document.hidden === true) { return } this.clear() @@ -329,7 +383,11 @@ function Client () { if (this.commander.isActive === true) { this.write(`${this.commander.query}${this.orca.f % 2 === 0 ? '_' : ''}`, this.grid.w * 0, this.orca.h + 1, this.grid.w * 4) } else { - this.write(this.orca.f < 25 ? `ver${this.version}` : `${Object.keys(this.source.cache).length} mods`, this.grid.w * 0, this.orca.h + 1, this.grid.w) + if (this.clock.isLinkEnabled()) { + this.write(`${this.numPeers} links`, this.grid.w * 0, this.orca.h + 1, this.grid.w) + } else { + this.write(this.orca.f < 25 ? `ver${this.version}` : `${Object.keys(this.source.cache).length} mods`, this.grid.w * 0, this.orca.h + 1, this.grid.w) + } this.write(`${this.orca.w}x${this.orca.h}`, this.grid.w * 1, this.orca.h + 1, this.grid.w) this.write(`${this.grid.w}/${this.grid.h}${this.tile.w !== 10 ? ' ' + (this.tile.w / 10).toFixed(1) : ''}`, this.grid.w * 2, this.orca.h + 1, this.grid.w) this.write(`${this.clock}`, this.grid.w * 3, this.orca.h + 1, this.grid.w, this.clock.isPuppet ? 3 : this.io.midi.isClock ? 11 : this.clock.isPaused ? 20 : 2) diff --git a/desktop/sources/scripts/clock.js b/desktop/sources/scripts/clock.js index 95086cfe..03e2d1ce 100644 --- a/desktop/sources/scripts/clock.js +++ b/desktop/sources/scripts/clock.js @@ -2,6 +2,9 @@ /* global Blob */ +const sourceClock = 1 +const sourceLink = 2 + function Clock (client) { const workerScript = 'onmessage = (e) => { setInterval(() => { postMessage(true) }, e.data)}' const worker = window.URL.createObjectURL(new Blob([workerScript], { type: 'text/javascript' })) @@ -9,6 +12,7 @@ function Clock (client) { this.isPaused = true this.timer = null this.isPuppet = false + this.puppetSource = null this.speed = { value: 120, target: 120 } @@ -29,11 +33,36 @@ function Clock (client) { this.setSpeed(this.speed.value + (this.speed.value < this.speed.target ? 1 : -1), null, true) } + this.isLinkEnabled = function () { + if (this.isPuppet && this.puppetSource == 2) { + return true + } else { + return false + } + } + + this.isExternalClockActive = function () { + if (this.isPuppet && this.puppetSource == 1) { + return true + } else { + return false + } + } + this.setSpeed = (value, target = null, setTimer = false) => { if (this.speed.value === value && this.speed.target === target && this.timer) { return } if (value) { this.speed.value = clamp(value, 60, 300) } if (target) { this.speed.target = clamp(target, 60, 300) } if (setTimer === true) { this.setTimer(this.speed.value) } + if (this.isLinkEnabled()) { this.setFrame(0) } + } + + this.setSpeedLink = (value) => { + client.link.setTempo(value) + if (!client.link.isPlaying()) { + this.setFrame(0) + client.update() + } } this.modSpeed = function (mod = 0, animate = false) { @@ -56,17 +85,21 @@ function Clock (client) { client.update() } - this.play = function (msg = false, midiStart = false) { - console.log('Clock', 'Play', msg, midiStart) + this.play = function (msg = false, midiStart = false, linkStart = false) { + console.log('Clock', 'Play', msg, midiStart, linkStart) if (this.isPaused === false && !midiStart) { return } this.isPaused = false - if (this.isPuppet === true) { + if (this.isExternalClockActive()) { console.warn('Clock', 'External Midi control') if (!pulse.frame || midiStart) { // no frames counted while paused (starting from no clock, unlikely) or triggered by MIDI clock START this.setFrame(0) // make sure frame aligns with pulse count for an accurate beat pulse.frame = 0 pulse.count = 5 // by MIDI standard next pulse is the beat } + } else if (this.isLinkEnabled() && !linkStart) { + console.warn('Clock', 'Ableton Link') + this.setSpeed(this.speed.target, this.speed.target, true) + client.link.play() } else { if (msg === true) { client.io.midi.sendClockStart() } this.setSpeed(this.speed.target, this.speed.target, true) @@ -77,8 +110,12 @@ function Clock (client) { console.log('Clock', 'Stop') if (this.isPaused === true) { return } this.isPaused = true - if (this.isPuppet === true) { + if (this.isExternalClockActive()) { console.warn('Clock', 'External Midi control') + } else if (this.isLinkEnabled()) { + console.warn('Clock', 'Ableton Link') + this.clearTimer() + client.link.stop() } else { if (msg === true || client.io.midi.isClock) { client.io.midi.sendClockStop() } this.clearTimer() @@ -99,9 +136,10 @@ function Clock (client) { this.tap = function () { pulse.count = (pulse.count + 1) % 6 pulse.last = performance.now() - if (!this.isPuppet) { + if (!this.isPuppet && !this.isLinkEnabled()) { console.log('Clock', 'Puppeteering starts..') this.isPuppet = true + this.puppetSource = sourceClock this.clearTimer() pulse.timer = setInterval(() => { if (performance.now() - pulse.last < 2000) { return } @@ -124,6 +162,7 @@ function Clock (client) { console.log('Clock', 'Puppeteering stops..') clearInterval(pulse.timer) this.isPuppet = false + this.puppetSource = null pulse.frame = 0 pulse.last = null if (!this.isPaused) { @@ -159,10 +198,18 @@ function Clock (client) { // UI + this.getUIMessage = function (offset) { + if (this.isLinkEnabled()) { + return `link${this.speed.value}` + } else { + return this.isExternalClockActive() ? 'midi' : `${this.speed.value}${offset}` + } + } + this.toString = function () { const diff = this.speed.target - this.speed.value const _offset = Math.abs(diff) > 5 ? (diff > 0 ? `+${diff}` : diff) : '' - const _message = this.isPuppet === true ? 'midi' : `${this.speed.value}${_offset}` + const _message = this.getUIMessage(_offset) const _beat = diff === 0 && client.orca.f % 4 === 0 ? '*' : '' return `${_message}${_beat}` } diff --git a/desktop/sources/scripts/commander.js b/desktop/sources/scripts/commander.js index 71ee1dd1..21f21220 100644 --- a/desktop/sources/scripts/commander.js +++ b/desktop/sources/scripts/commander.js @@ -43,9 +43,28 @@ function Commander (client) { play: (p) => { client.clock.play() }, stop: (p) => { client.clock.stop() }, run: (p) => { client.run() }, + link: (p) => { client.toggleLink() }, // Time - apm: (p) => { client.clock.setSpeed(null, p.int) }, - bpm: (p) => { client.clock.setSpeed(p.int, p.int, true) }, + apm: (p) => { + if (client.clock.isLinkEnabled()) { + client.clock.setSpeed(null, p.int) + client.clock.setSpeedLink(p.int) + } else { + client.clock.setSpeed(null, p.int) + } + }, + bpm: (p) => { + if (client.clock.isLinkEnabled()) { + if (client.link.isPlaying()) { + client.clock.setSpeed(p.int, p.int, true) + } else { + client.clock.setSpeed(p.int, p.int, false) + } + client.clock.setSpeedLink(p.int) + } else { + client.clock.setSpeed(p.int, p.int, true) + } + }, frame: (p) => { client.clock.setFrame(p.int) }, rewind: (p) => { client.clock.setFrame(client.orca.f - p.int) }, skip: (p) => { client.clock.setFrame(client.orca.f + p.int) }, diff --git a/desktop/sources/scripts/core/io/midi.js b/desktop/sources/scripts/core/io/midi.js index 7f4875a9..db18225e 100644 --- a/desktop/sources/scripts/core/io/midi.js +++ b/desktop/sources/scripts/core/io/midi.js @@ -122,23 +122,25 @@ function Midi (client) { } this.receive = function (msg) { - switch (msg.data[0]) { - // Clock - case 0xF8: - client.clock.tap() - break - case 0xFA: - console.log('MIDI', 'Start Received') - client.clock.play(false, true) - break - case 0xFB: - console.log('MIDI', 'Continue Received') - client.clock.play() - break - case 0xFC: - console.log('MIDI', 'Stop Received') - client.clock.stop() - break + if (!client.clock.isLinkEnabled()) { + switch (msg.data[0]) { + // Clock + case 0xF8: + client.clock.tap() + break + case 0xFA: + console.log('MIDI', 'Start Received') + client.clock.play(false, true) + break + case 0xFB: + console.log('MIDI', 'Continue Received') + client.clock.play() + break + case 0xFC: + console.log('MIDI', 'Stop Received') + client.clock.stop() + break + } } }