├── .gitignore ├── README.md ├── fourhandslogo.png ├── index.js ├── loop.html ├── package.json └── public ├── audio.png ├── css └── style.css ├── favicon.png ├── fourhandslogo.png ├── fourhandslogo.svg ├── index.html ├── index_backwards.html ├── js ├── main.js └── main_backwards.js ├── keyboard.svg └── keyboard2.svg /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | coverage/ 3 | node_modules/ 4 | npm-debug.log 5 | package-lock.json 6 | test/*.log 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fourhands, the p2p piano 2 | 3 | ![Fourhands logo](fourhandslogo.png) 4 | 5 | Fourhands uses WebRTC to establish p2p connections for minimal latency 2-person 6 | jamming using MIDI keyboards. 7 | 8 | For seamless collaboration on Fourhands, one-way time of 20 ms or less is 9 | ideal. Typically this can be achieved on wired connections for fairly nearby 10 | players (within 35 miles / 50 km. Anecdotally, a friend has also achieved this 11 | between SF and LA -- 350 miles / 500 km). 12 | 13 | Try it here: [create a room and share the link to invite another 14 | player](https://fourhands.jminjie.com). 15 | 16 | ## Prior art 17 | Online jamming has been achieved already, but often not in an accessible and 18 | unstructured way. 19 | 20 | - [Jacktrip](https://news.stanford.edu/2020/09/18/jacktrip-software-allows-musicians-sync-performances-online/) 21 | allows fairly nearby players to jam in real time, but requires a local 22 | machine with a static IP (an AWS instance will not work, since the audio 23 | cable must be plugged in to the machine). 24 | - A few different apps including 25 | [Endless](https://www.theverge.com/2020/3/31/21201913/endlesss-app-music-remotely-jam-out-loops-real-time), 26 | [NinJam](https://www.cockos.com/ninjam/), and [Jammr](https://jammr.net/) 27 | allow collaborative looping, in which your playing is shared n measures after 28 | you play it. 29 | - [Jamlink](https://musicplayers.com/2011/11/musicianlink-jamlink/) shares 30 | audio for remote jamming but requires custom hardware. 31 | - My own [collaborative piano](https://piano.jminjie.com) allows multiple 32 | participants to share a piano (like Google Docs for piano). The latency is 33 | too high to jam, but this does work for sharing ideas when songwriting 34 | remotely. 35 | - See an [overview of other options 36 | here](https://acousticguitar.com/virtual-jamming-the-latest-tools-for-playing-together-in-real-time/). 37 | 38 | Comparatively, Fourhands is a simple in-browser solution for which all you need 39 | is a MIDI keyboard and an optionally wired internet. Because only MIDI data is 40 | shared, it is limited to instruments which can output MIDI. 41 | 42 | ## Development 43 | For self hosting, after cloning the repo run `npm install` to install necessary 44 | packages. 45 | 46 | Deploy with `node index.js debug`. This will serve the files 47 | needed for the page (index.html and js/) and also start the NodeJs server 48 | (index.js). 49 | 50 | If you have local SSL keys you can deploy with HTTPS using `node index.js`. 51 | Note that a secure connection is required for MIDI input (localhost is secure 52 | by default). 53 | 54 | Client should be available at localhost:30001. 55 | 56 | ## Browser support 57 | Chrome and Edge work. Firefox does not work as there is [no support for 58 | MIDI](https://developer.mozilla.org/en-US/docs/Web/API/MIDIAccess). Other 59 | browsers are not tested, but should work if they support MIDI, Tone.js, web 60 | sockets, and WebRTC. 61 | -------------------------------------------------------------------------------- /fourhandslogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jminjie/fourhands/9aa31d962f0be515049979d42731ec4da84bfb09/fourhandslogo.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var os = require('os'); 4 | var nodeStatic = require('node-static'); 5 | var socketIO = require('socket.io'); 6 | 7 | var fileServer = new nodeStatic.Server('./public'); 8 | 9 | const DEBUG = (process.argv[2] == "debug") ? true : false; 10 | 11 | if (DEBUG) { 12 | console.log("Running in debug mode. Note that MIDI is not available without HTTPS"); 13 | console.log("Up on localhost:30001"); 14 | const http = require('http'); 15 | var app = http.createServer(function(req, res) { 16 | log(req.url); 17 | fileServer.serve(req, res); 18 | }).listen(30001); 19 | } else { 20 | const Https = require('https'); 21 | const Fs = require('fs'); 22 | var secureApp = Https.createServer({ 23 | key: Fs.readFileSync('/etc/letsencrypt/live/jminjie.com/privkey.pem'), 24 | cert: Fs.readFileSync('/etc/letsencrypt/live/jminjie.com/cert.pem'), 25 | ca: Fs.readFileSync('/etc/letsencrypt/live/jminjie.com/chain.pem') 26 | }, function(req, res) { 27 | fileServer.serve(req, res); 28 | }).listen(30001); 29 | } 30 | 31 | function log(m) { 32 | console.log(m); 33 | } 34 | 35 | var io = socketIO.listen(DEBUG ? app : secureApp); 36 | io.sockets.on('connection', function(socket) { 37 | 38 | socket.on('message', function({ m, r }) { 39 | log('Client said: ', m); 40 | socket.broadcast.to(r).emit('message', m); 41 | }); 42 | 43 | 44 | socket.on('create or join', function(room) { 45 | log('Received request to create or join room ' + room); 46 | 47 | var clientsInRoom = io.sockets.adapter.rooms[room]; 48 | var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0; 49 | log('Room ' + room + ' currently has ' + numClients + ' client(s)'); 50 | 51 | if (numClients === 0) { 52 | socket.join(room); 53 | log('Client ID ' + socket.id + ' created room ' + room); 54 | socket.emit('created', room, socket.id); 55 | } else if (numClients === 1) { 56 | log('Client ID ' + socket.id + ' joined room ' + room); 57 | io.sockets.in(room).emit('join', room); 58 | socket.join(room); 59 | socket.emit('joined', room, socket.id); 60 | io.sockets.in(room).emit('ready', room); 61 | //socket.broadcast.emit('ready', room); 62 | } else { // max two clients 63 | socket.emit('full', room); 64 | } 65 | }); 66 | 67 | socket.on('ipaddr', function() { 68 | var ifaces = os.networkInterfaces(); 69 | for (var dev in ifaces) { 70 | ifaces[dev].forEach(function(details) { 71 | if (details.family === 'IPv4' && details.address !== '127.0.0.1') { 72 | socket.emit('ipaddr', details.address); 73 | } 74 | }); 75 | } 76 | }); 77 | 78 | socket.on('bye', function(room) { 79 | console.log(`Peer said bye on room ${room}.`); 80 | io.sockets.in(room).emit('bye', room); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /loop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

How to use looper

4 |
    5 |
  1. Each player gets their own looper. Each looper can only have one layer
  2. 6 |
  3. Press "Record loop" to start recording your loop. Will automatically set the looper instrument to your current sampler.
  4. 7 |
  5. Press "End record " to finish recording the loop, or "Play/pause" to finish recording and immediately play back
  6. 8 |
  7. Press "Play/Pause" to play or pause the loop
  8. 9 |
  9. To record a new loop press "Record loop" again
  10. 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fourhands", 3 | "version": "0.0.2", 4 | "description": "two piano piano", 5 | "dependencies": { 6 | "node-static": "^0.7.11", 7 | "socket.io": "^2.3.0" 8 | }, 9 | "main": "index.js", 10 | "devDependencies": {}, 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC" 17 | } 18 | -------------------------------------------------------------------------------- /public/audio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jminjie/fourhands/9aa31d962f0be515049979d42731ec4da84bfb09/public/audio.png -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 1em 1em 0em 1em; 3 | width: 610px; 4 | margin: auto; 5 | background: #E3ECF8; 6 | } 7 | 8 | h1, h2 { 9 | text-align: center; 10 | } 11 | 12 | #footer { 13 | position:static; 14 | left:0px; 15 | bottom:0px; 16 | height:130px; 17 | width:100%; 18 | } 19 | 20 | /* IE 6 */ 21 | * html #footer { 22 | position:absolute; 23 | top:expression((0-(footer.offsetHeight)+(document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight)+(ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop))+'px'); 24 | } 25 | 26 | #pts_canvas { 27 | position: relative; 28 | top: 5px; 29 | left: 5px; 30 | height:30px; 31 | } 32 | 33 | #pts2_canvas { 34 | position: relative; 35 | top: 5px; 36 | left: 5px; 37 | height:30px; 38 | } 39 | 40 | .sound-overlay { 41 | text-align: center; 42 | z-index: 20; 43 | border-radius: 6px; 44 | position:fixed; 45 | width: 320px; 46 | height: 230px; 47 | top: 50%; 48 | left: 50%; 49 | margin-left: -180px; /* Negative half of width. */ 50 | margin-top: -180px; /* Negative half of height. */ 51 | font-size: 16px; 52 | line-height: 24px; 53 | padding: 20px 20px 20px 20px; 54 | box-shadow: 0 0 0 1500px rgba(0, 0, 0, .5); 55 | background: white; 56 | .accept-sound { 57 | cursor: pointer; 58 | } 59 | } 60 | 61 | .d-none { 62 | display: none; 63 | } 64 | 65 | .hide { 66 | display: none; 67 | } 68 | 69 | .gainslider { 70 | } 71 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jminjie/fourhands/9aa31d962f0be515049979d42731ec4da84bfb09/public/favicon.png -------------------------------------------------------------------------------- /public/fourhandslogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jminjie/fourhands/9aa31d962f0be515049979d42731ec4da84bfb09/public/fourhandslogo.png -------------------------------------------------------------------------------- /public/fourhandslogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fourhands 5 | 6 | 7 | 8 | 9 | 10 |

11 |
    12 |
  1. 13 |
  2. Plug in a MIDI device or use keys ASDFGHJK. Try refreshing if device is not detected.
  3. 14 |
  4. No player connected. Share the URL with another player.
  5. 15 |
16 |
17 | 18 | 32 | 33 | 34 | 35 | 36 | Decay: 37 | Release: 38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 | 46 | 60 | 61 | 62 | Decay: 63 | Release: 64 | 65 |
66 |
67 |
68 |
69 | 70 |
71 | 72 | 73 | 74 | (Instructions) 75 |
76 |
77 | 78 | 92 | Release: 93 | Gain: 94 | Decay: 95 |
96 |
97 |

Diagnostics

98 |
99 | 100 | 101 |

For seamless jamming, one-way time of 20 ms or less is ideal. Typically this can be achieved on 102 | wired connections for fairly nearby players (within 35 miles / 50 km).

103 |
Round trip time (ms): 
104 |
Est oneway time (ms): 
105 |
106 |
107 |
108 |

This website makes sound

109 |

You will hear your own playing as well as the other player in the room

110 | 111 | 112 |
113 | 114 | 115 | 116 | 117 | 118 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /public/index_backwards.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fourhands 5 | 6 | 7 | 8 | 9 | 10 |

11 |
    12 |
  1. 13 |
  2. Plug in a MIDI device or use keys ASDFGHJK. Try refreshing if device is not detected.
  3. 14 |
  4. No player connected. Share the URL with another player.
  5. 15 |
16 |
17 | 18 | 32 | 33 | 34 | 35 | 36 | Decay: 37 | Release: 38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 | 46 | 60 | 61 | 62 | Decay: 63 | Release: 64 | 65 |
66 |
67 |
68 |
69 | 70 |
71 | 72 | 73 | 74 | (Instructions) 75 |
76 |
77 | 78 | 92 | Release: 93 | Gain: 94 | Decay: 95 |
96 |
97 |

Diagnostics

98 |
99 | 100 | 101 |

For seamless jamming, one-way time of 20 ms or less is ideal. Typically this can be achieved on 102 | wired connections for fairly nearby players (within 35 miles / 50 km).

103 |
Round trip time (ms): 
104 |
Est oneway time (ms): 
105 |
106 |
107 |
108 |

This website makes sound

109 |

You will hear your own playing as well as the other player in the room

110 | 111 | 112 |
113 | 114 | 115 | 116 | 117 | 118 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | /***************************************************************************r 2 | * * 3 | * Initial setup 4 | ****************************************************************************/ 5 | 6 | var configuration = { 7 | 'iceServers': [{ 8 | 'urls': 'stun:stun1.l.google.com:19302' 9 | }] 10 | }; 11 | 12 | var sendBtn = document.getElementById('send'); 13 | 14 | // Attach event handlers 15 | sendBtn.addEventListener('click', sendTestMessage); 16 | 17 | // Disable send buttons by default. 18 | sendBtn.disabled = true; 19 | 20 | // Create a random room if not already present in the URL. 21 | var isInitiator; 22 | 23 | var room = window.location.hash.substring(1); 24 | if (!room) { 25 | room = window.location.hash = randomToken(); 26 | } 27 | 28 | /**************************************************************************** 29 | * Signaling server 30 | ****************************************************************************/ 31 | 32 | // Connect to the signaling server 33 | var socket = io.connect(); 34 | 35 | socket.on('created', function(room, clientId) { 36 | console.log('Created room', room, '- my client ID is', clientId); 37 | isInitiator = true; 38 | }); 39 | 40 | socket.on('joined', function(room, clientId) { 41 | console.log('This peer has joined room', room, 'with client ID', clientId); 42 | isInitiator = false; 43 | createPeerConnection(isInitiator, configuration); 44 | }); 45 | 46 | socket.on('ready', function() { 47 | console.log('Socket is ready'); 48 | createPeerConnection(isInitiator, configuration); 49 | }); 50 | 51 | socket.on('full', function(room) { 52 | alert('Room ' + room + ' is full. Try again later.'); 53 | }); 54 | 55 | socket.on('message', function(message) { 56 | //console.log('Client received message:', message); 57 | signalingMessageCallback(message); 58 | }); 59 | 60 | // Joining a room. 61 | socket.emit('create or join', room); 62 | 63 | socket.on('bye', function(room) { 64 | console.log(`Peer leaving room ${room}.`); 65 | sendBtn.disabled = true; 66 | document.getElementById("peer-status").innerHTML = "Lost connection from partner."; 67 | document.getElementById("peer-status").style.color = "#000"; 68 | document.getElementById("ping").innerHTML = ""; 69 | document.getElementById("ping2").innerHTML = ""; 70 | // If peer did not create the room, re-enter to be creator. 71 | if (!isInitiator) { 72 | window.location.reload(); 73 | } 74 | }); 75 | 76 | window.addEventListener('unload', function() { 77 | console.log(`Unloading window. Notifying peers in ${room}.`); 78 | socket.emit('bye', room); 79 | }); 80 | 81 | window.addEventListener('load', function() { 82 | if (document.cookie.indexOf("cookie_soundon=") < 0) { 83 | document.querySelector('.sound-overlay').classList.remove('d-none'); 84 | } 85 | }); 86 | 87 | /** 88 | * Send message to signaling server 89 | */ 90 | function sendMessageToServer(message) { 91 | //console.log('Client sending message to server:', message, ' room:', room); 92 | socket.emit('message', { m: message, r: room }) 93 | } 94 | 95 | /**************************************************************************** 96 | * WebRTC peer connection and data channel 97 | ****************************************************************************/ 98 | 99 | var peerConn; 100 | var dataChannel; 101 | 102 | function signalingMessageCallback(message) { 103 | if (message == null) { 104 | console.log("signalingMessageCallback is ignoring null message"); 105 | return; 106 | } 107 | if (message.type === 'offer') { 108 | console.log('Got offer. Sending answer to peer.'); 109 | peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, 110 | logError); 111 | peerConn.createAnswer(onLocalSessionCreated, logError); 112 | 113 | } else if (message.type === 'answer') { 114 | console.log('Got answer.'); 115 | peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, 116 | logError); 117 | 118 | } else if (message.type === 'candidate') { 119 | peerConn.addIceCandidate(new RTCIceCandidate({ 120 | candidate: message.candidate, 121 | sdpMLineIndex: message.label, 122 | sdpMid: message.id 123 | })); 124 | 125 | } 126 | } 127 | 128 | function createPeerConnection(isInitiator, config) { 129 | console.log('Creating Peer connection as initiator?', isInitiator, 'config:', 130 | config); 131 | peerConn = new RTCPeerConnection(config); 132 | 133 | // send any ice candidates to the other peer 134 | peerConn.onicecandidate = function(event) { 135 | //console.log('icecandidate event:', event); 136 | if (event.candidate) { 137 | sendMessageToServer({ 138 | type: 'candidate', 139 | label: event.candidate.sdpMLineIndex, 140 | id: event.candidate.sdpMid, 141 | candidate: event.candidate.candidate 142 | }); 143 | } else { 144 | console.log('End of candidates.'); 145 | } 146 | }; 147 | 148 | if (isInitiator) { 149 | console.log('Creating Data Channel'); 150 | dataChannel = peerConn.createDataChannel('midi-data'); 151 | onDataChannelCreated(dataChannel); 152 | 153 | console.log('Creating an offer'); 154 | peerConn.createOffer() 155 | .then(function(offer) { 156 | return peerConn.setLocalDescription(offer); 157 | }) 158 | .then(() => { 159 | console.log('sending local desc:', peerConn.localDescription); 160 | sendMessageToServer(peerConn.localDescription); 161 | }) 162 | .catch(logError); 163 | 164 | } else { 165 | peerConn.ondatachannel = function(event) { 166 | console.log('ondatachannel:', event.channel); 167 | dataChannel = event.channel; 168 | onDataChannelCreated(dataChannel); 169 | }; 170 | } 171 | } 172 | 173 | function onLocalSessionCreated(desc) { 174 | console.log('local session created:', desc); 175 | peerConn.setLocalDescription(desc).then(function() { 176 | console.log('sending local desc:', peerConn.localDescription); 177 | sendMessageToServer(peerConn.localDescription); 178 | }).catch(logError); 179 | } 180 | 181 | function peerConnected() { 182 | return dataChannel && peerConn.connectionState == "connected"; 183 | } 184 | 185 | var pingTime = 0; 186 | 187 | function sendPing() { 188 | if (peerConnected()) { 189 | dataChannel.send('ping'); 190 | pingTime = Date.now(); 191 | } else { 192 | } 193 | } 194 | 195 | function onDataChannelCreated(channel) { 196 | console.log('onDataChannelCreated:', channel); 197 | 198 | channel.onopen = function() { 199 | console.log('CHANNEL opened!!!'); 200 | document.getElementById("peer-status").innerHTML = "Partner connected."; 201 | document.getElementById("peer-status").style.color = "green"; 202 | sendPing(); 203 | window.setInterval(sendPing, 1000); 204 | sendBtn.disabled = false; 205 | // when connecting, send sampler info in case player changed already 206 | onSetMySamplerButtonPress(); 207 | onSetLoopSamplerButtonPress(); 208 | }; 209 | 210 | channel.onclose = function () { 211 | console.log('Channel closed.'); 212 | sendBtn.disabled = true; 213 | document.getElementById("peer-status").innerHTML = "Lost connection from partner."; 214 | document.getElementById("peer-status").style.color = "#000"; 215 | document.getElementById("ping").innerHTML = ""; 216 | document.getElementById("ping2").innerHTML = ""; 217 | } 218 | 219 | channel.onmessage = function onmessage(event) { 220 | if (typeof event.data === 'string') { 221 | if (event.data == "ping") { 222 | dataChannel.send("pong"); 223 | return; 224 | } 225 | if (event.data == "pong") { 226 | let ping = Date.now() - pingTime; 227 | document.getElementById("ping").innerHTML = ping; 228 | document.getElementById("ping2").innerHTML = Math.floor(ping/2); 229 | return; 230 | } 231 | if (event.data.substring(0, 9) == "mySampler") { 232 | let samplerData = event.data.split(' '); 233 | changeTheirSampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 234 | console.log(event.data); 235 | return; 236 | } 237 | // only you can set your own loop, so we don't need to listen for myLoopSampler 238 | if (event.data.substring(0, 16) == "theirLoopSampler") { 239 | let samplerData = event.data.split(' '); 240 | changeTheirLoopSampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 241 | console.log(event.data); 242 | return; 243 | } 244 | if (event.data.substring(0, 12) == "theirSampler") { 245 | let samplerData = event.data.split(' '); 246 | changeMySampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 247 | console.log(event.data); 248 | return; 249 | } 250 | console.log(event.data, Date.now()); 251 | let midiData = event.data.split('-'); 252 | if (midiData.length == 3) { 253 | // looks like midi data to me, lets just try to play it 254 | playMidi(THEM, parseInt(midiData[0]), parseInt(midiData[1]), parseInt(midiData[2])); 255 | } 256 | if (midiData.length == 4 && midiData[0] == "LOOP") { 257 | // loop midi data 258 | playMidi(THEIR_LOOP, parseInt(midiData[1]), parseInt(midiData[2]), parseInt(midiData[3])); 259 | } 260 | return; 261 | } 262 | }; 263 | } 264 | 265 | function playTheirMidi(command, byte1, byte2) { 266 | } 267 | 268 | /**************************************************************************** 269 | * MIDI things 270 | ****************************************************************************/ 271 | 272 | var NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] 273 | 274 | // pedal for both samplers 275 | var myPedal = false; 276 | var theirPedal = false; 277 | var myLoopPedal = false; 278 | var theirLoopPedal = false; 279 | 280 | // currently pressed keys for both samplers (used when releasing pedal) 281 | var myPressedKeys = new Set() 282 | var theirPressedKeys = new Set() 283 | var myLoopPressedKeys = new Set() 284 | var theirLoopPressedKeys = new Set() 285 | 286 | const ME = 0; 287 | const THEM = 1; 288 | const MY_LOOP = 2; 289 | const THEIR_LOOP = 3; 290 | 291 | // gain for both samplers 292 | var myGain = 1.0; 293 | var theirGain = 1.0; 294 | var myLoopGain = 1.0; 295 | var theirLoopGain = 1.0; 296 | 297 | Tone.context.latencyHint = "fastest"; 298 | 299 | // octave for computer keyboard entry 300 | var octave = 0; 301 | 302 | // local loop data 303 | document.getElementById("startLoopButton").disabled = false; 304 | document.getElementById("stopLoopButton").disabled = true; 305 | document.getElementById("playPauseLoopButton").disabled = true; 306 | var loopStartTime; 307 | var loopLength; 308 | var recording = false; 309 | var loopData = []; 310 | var playingLoop = false; 311 | var loopId; 312 | 313 | if (navigator.requestMIDIAccess) { 314 | console.log('This browser supports WebMIDI!'); 315 | document.getElementById("browser-status").innerHTML = "Browser supports MIDI"; 316 | document.getElementById("browser-status").style.color = "green"; 317 | } else { 318 | console.log('WebMIDI is not supported in this browser.'); 319 | document.getElementById("browser-status").innerHTML = "No browser support for MIDI. Consider trying Chrome or Edge"; 320 | document.getElementById("browser-status").style.color = "red"; 321 | } 322 | 323 | try { 324 | navigator.requestMIDIAccess() 325 | .then(onMIDISuccess, onMIDIFailure); 326 | } catch (e) { 327 | console.log(e); 328 | } 329 | 330 | const default_reverb = new Tone.Reverb(1.5).toDestination(); 331 | 332 | var mySampler = new Tone.Sampler({ 333 | urls: { 334 | A1: "A1.mp3", 335 | A2: "A2.mp3", 336 | A3: "A3.mp3", 337 | A4: "A4.mp3", 338 | A5: "A5.mp3", 339 | A6: "A6.mp3", 340 | A7: "A7.mp3", 341 | }, 342 | release: 0.6, 343 | baseUrl: "https://tonejs.github.io/audio/salamander/", 344 | }).connect(default_reverb).toDestination(); 345 | 346 | var myLoopSampler = new Tone.Sampler({ 347 | urls: { 348 | A1: "A1.mp3", 349 | A2: "A2.mp3", 350 | A3: "A3.mp3", 351 | A4: "A4.mp3", 352 | A5: "A5.mp3", 353 | A6: "A6.mp3", 354 | A7: "A7.mp3", 355 | }, 356 | release: 0.6, 357 | baseUrl: "https://tonejs.github.io/audio/salamander/", 358 | }).connect(default_reverb).toDestination(); 359 | 360 | var theirLoopSampler = new Tone.Sampler({ 361 | urls: { 362 | A1: "A1.mp3", 363 | A2: "A2.mp3", 364 | A3: "A3.mp3", 365 | A4: "A4.mp3", 366 | A5: "A5.mp3", 367 | A6: "A6.mp3", 368 | A7: "A7.mp3", 369 | }, 370 | release: 0.6, 371 | baseUrl: "https://tonejs.github.io/audio/salamander/", 372 | }).connect(default_reverb).toDestination(); 373 | 374 | var theirSampler = new Tone.Sampler({ 375 | urls: { 376 | A1: "A1.mp3", 377 | A2: "A2.mp3", 378 | A3: "A3.mp3", 379 | A4: "A4.mp3", 380 | A5: "A5.mp3", 381 | A6: "A6.mp3", 382 | A7: "A7.mp3", 383 | }, 384 | release: 0.6, 385 | baseUrl: "https://tonejs.github.io/audio/salamander/", 386 | }).connect(default_reverb).toDestination(); 387 | 388 | 389 | function sendTestMessage() { 390 | if (!dataChannel) { 391 | logError('Connection has not been initiated. ' + 392 | 'Get two peers in the same room first'); 393 | return; 394 | } else if (dataChannel.readyState === 'closed') { 395 | logError('Connection was lost. Peer closed the connection.'); 396 | return; 397 | } 398 | dataChannel.send("Test message"); 399 | sendPing(); 400 | } 401 | 402 | function randomToken() { 403 | return Math.floor((1 + Math.random()) * 1e16).toString(16).substring(8); 404 | } 405 | 406 | function logError(err) { 407 | if (!err) return; 408 | if (typeof err === 'string') { 409 | console.warn(err); 410 | } else { 411 | console.warn(err.toString(), err); 412 | } 413 | } 414 | 415 | function playMidi(who, command, byte1, byte2) { 416 | switch (command) { 417 | case 144: // keyDown 418 | if (byte2 > 0) { 419 | keyDown(who, byte1, byte2); 420 | } else { 421 | keyUp(who, byte1); 422 | } 423 | break; 424 | case 128: // keyUp 425 | keyUp(who, byte1); 426 | break; 427 | case 176: // special command 428 | if (byte1 == 64) { // pedal 429 | if (byte2 == 0) { 430 | pedalOff(who); 431 | } else { 432 | pedalOn(who); 433 | } 434 | } 435 | break; 436 | } 437 | if (recording && who == ME) { 438 | addToLoop(command, byte1, byte2); 439 | } 440 | } 441 | 442 | function onSetMySamplerButtonPress() { 443 | //let url = document.getElementById("mysamplerurl").value; 444 | 445 | var dropdown = document.getElementById("dropdownsampler"); 446 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 447 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 448 | 449 | let rel = document.getElementById("mysamplerrelease").value; 450 | let gain = document.getElementById("mygain").value; 451 | let decay = document.getElementById("mydecay").value; 452 | changeMySampler(url, rel, gain, decay, samplerValue); 453 | if (peerConnected()) { 454 | dataChannel.send("mySampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 455 | } 456 | } 457 | 458 | function onSetLoopSamplerButtonPress() { 459 | var dropdown = document.getElementById("loopdropdownsampler"); 460 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 461 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 462 | 463 | let rel = document.getElementById("loopsamplerrelease").value; 464 | let gain = document.getElementById("loopgain").value; 465 | let decay = document.getElementById("loopdecay").value; 466 | changeLoopSampler(url, rel, gain, decay, samplerValue); 467 | if (peerConnected()) { 468 | dataChannel.send("theirLoopSampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 469 | } 470 | } 471 | 472 | function changeMySampler(url, rel, gain, decay, samplerValue) { 473 | console.log("changeMySampler"); 474 | fetch(url + 'config.json') 475 | .then(response => response.json()) 476 | .then(function (mapping) { 477 | mySampler = new Tone.Sampler({ 478 | urls: mapping, 479 | release: rel, 480 | baseUrl: url, 481 | }).toDestination(); 482 | if (parseFloat(decay) > 0) { 483 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 484 | mySampler.connect(reverb); 485 | } 486 | myGain = parseFloat(gain); 487 | sound = Sound.from( mySampler, mySampler.context ).analyze(256); 488 | //document.getElementById("mysamplerurl").value = url; 489 | document.getElementById("dropdownsampler").value = samplerValue; 490 | document.getElementById("mysamplerrelease").value = rel; 491 | document.getElementById("mygain").value = gain; 492 | document.getElementById("mydecay").value = decay; 493 | }); 494 | } 495 | 496 | function changeLoopSampler(url, rel, gain, decay, samplerValue) { 497 | console.log("changeLoopSampler"); 498 | fetch(url + 'config.json') 499 | .then(response => response.json()) 500 | .then(function (mapping) { 501 | myLoopSampler = new Tone.Sampler({ 502 | urls: mapping, 503 | release: rel, 504 | baseUrl: url, 505 | }).toDestination(); 506 | if (parseFloat(decay) > 0) { 507 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 508 | myLoopSampler.connect(reverb); 509 | } 510 | myLoopGain = parseFloat(gain); 511 | document.getElementById("loopdropdownsampler").value = samplerValue; 512 | document.getElementById("loopsamplerrelease").value = rel; 513 | document.getElementById("loopgain").value = gain; 514 | document.getElementById("loopdecay").value = decay; 515 | }); 516 | } 517 | 518 | function changeTheirLoopSampler(url, rel, gain, decay) { 519 | console.log("changeTheirLoopSampler"); 520 | fetch(url + 'config.json') 521 | .then(response => response.json()) 522 | .then(function (mapping) { 523 | theirLoopSampler = new Tone.Sampler({ 524 | urls: mapping, 525 | release: rel, 526 | baseUrl: url, 527 | }).toDestination(); 528 | if (parseFloat(decay) > 0) { 529 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 530 | theirLoopSampler.connect(reverb); 531 | } 532 | theirLoopGain = parseFloat(gain); 533 | }); 534 | } 535 | 536 | function onSetTheirSamplerButtonPress() { 537 | var dropdown = document.getElementById("theirdropdownsampler"); 538 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 539 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 540 | 541 | let rel = document.getElementById("theirsamplerrelease").value; 542 | let gain = document.getElementById("theirgain").value; 543 | let decay = document.getElementById("theirdecay").value; 544 | changeTheirSampler(url, rel, gain, decay, samplerValue); 545 | if (peerConnected()) { 546 | dataChannel.send("theirSampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 547 | } 548 | } 549 | 550 | function changeTheirSampler(url, rel, gain, decay, samplerValue) { 551 | console.log("changeTheirSampler"); 552 | fetch(url + 'config.json') 553 | .then(response => response.json()) 554 | .then(function (mapping) { 555 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 556 | theirSampler = new Tone.Sampler({ 557 | urls: mapping, 558 | release: rel, 559 | baseUrl: url, 560 | }).toDestination(); 561 | if (parseFloat(decay) > 0) { 562 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 563 | theirSampler.connect(reverb); 564 | } 565 | theirGain = parseFloat(gain); 566 | sound2 = Sound.from( theirSampler, theirSampler.context ).analyze(256); 567 | document.getElementById("theirdropdownsampler").value = samplerValue; 568 | document.getElementById("theirsamplerrelease").value = rel; 569 | document.getElementById("theirgain").value = gain; 570 | document.getElementById("theirdecay").value = decay; 571 | }); 572 | } 573 | 574 | function keyDown(who, midiValue, velocity) { 575 | let note = getNote(midiValue); 576 | if (who === ME) { 577 | myPressedKeys.add(note); 578 | mySampler.triggerAttack(note, Tone.context.currentTime, velocity*myGain/120) 579 | } else if (who === THEM) { 580 | theirPressedKeys.add(note); 581 | theirSampler.triggerAttack(note, Tone.context.currentTime, velocity*theirGain/120) 582 | } else if (who === MY_LOOP) { 583 | myLoopPressedKeys.add(note); 584 | myLoopSampler.triggerAttack(note, Tone.context.currentTime, velocity*myLoopGain/120) 585 | } else if (who === THEIR_LOOP) { 586 | theirLoopPressedKeys.add(note); 587 | theirLoopSampler.triggerAttack(note, Tone.context.currentTime, velocity*theirLoopGain/120) 588 | } 589 | } 590 | 591 | function keyUp(who, midiValue) { 592 | let note = getNote(midiValue); 593 | if (who === ME) { 594 | myPressedKeys.delete(note) 595 | if (!myPedal) { 596 | mySampler.triggerRelease(note, Tone.context.currentTime) 597 | } 598 | } else if (who === THEM) { 599 | theirPressedKeys.delete(note) 600 | if (!theirPedal) { 601 | theirSampler.triggerRelease(note, Tone.context.currentTime) 602 | } 603 | } else if (who === MY_LOOP) { 604 | myLoopPressedKeys.delete(note) 605 | if (!myLoopPedal) { 606 | myLoopSampler.triggerRelease(note, Tone.context.currentTime) 607 | } 608 | } else if (who === THEIR_LOOP) { 609 | theirLoopPressedKeys.delete(note) 610 | if (!theirLoopPedal) { 611 | theirLoopSampler.triggerRelease(note, Tone.context.currentTime) 612 | } 613 | } 614 | } 615 | 616 | function getNote(midiValue) { 617 | let noteLetter = NOTES[midiValue%12]; 618 | let octave = Math.floor(midiValue/12)-1; 619 | return noteLetter + octave; 620 | } 621 | 622 | function onMIDIFailure() { 623 | console.log('Could not access your MIDI devices.'); 624 | } 625 | 626 | function onMIDISuccess(midiAccess) { 627 | console.log(midiAccess); 628 | 629 | var inputs = midiAccess.inputs; 630 | var outputs = midiAccess.outputs; 631 | var deviceInfoMessage = "List of devices: ["; 632 | for (var input of midiAccess.inputs.values()) { 633 | deviceInfoMessage += input.name + ", "; 634 | input.onmidimessage = onMidiMessage; 635 | } 636 | deviceInfoMessage += "]"; 637 | if (inputs.size > 0) { 638 | document.getElementById("midi-status").innerHTML = deviceInfoMessage; 639 | document.getElementById("midi-status").style.color = "green"; 640 | } 641 | } 642 | 643 | document.addEventListener('keydown', function(event) { 644 | if (event.repeat == true) { 645 | return; 646 | } 647 | if (event.srcElement.localName == "input") { 648 | return; 649 | } 650 | let midiKeyCode = -1; 651 | switch (event.code) { 652 | case "KeyA": 653 | midiKeyCode = 60; 654 | break; 655 | case "KeyW": 656 | midiKeyCode = 61; 657 | break; 658 | case "KeyS": 659 | midiKeyCode = 62; 660 | break; 661 | case "KeyE": 662 | midiKeyCode = 63; 663 | break; 664 | case "KeyD": 665 | midiKeyCode = 64; 666 | break; 667 | case "KeyF": 668 | midiKeyCode = 65; 669 | break; 670 | case "KeyT": 671 | midiKeyCode = 66; 672 | break; 673 | case "KeyG": 674 | midiKeyCode = 67; 675 | break; 676 | case "KeyY": 677 | midiKeyCode = 68; 678 | break; 679 | case "KeyH": 680 | midiKeyCode = 69; 681 | break; 682 | case "KeyU": 683 | midiKeyCode = 70; 684 | break; 685 | case "KeyJ": 686 | midiKeyCode = 71; 687 | break; 688 | case "KeyK": 689 | midiKeyCode = 72; 690 | break; 691 | case "KeyZ": 692 | octave--; 693 | break; 694 | case "KeyX": 695 | octave++; 696 | break; 697 | } 698 | if (midiKeyCode != -1) { 699 | midiKeyCode += octave*12; 700 | playMidi(ME, 144, midiKeyCode, 80); 701 | if (peerConnected()) { 702 | let midiInfo = '144-' + midiKeyCode + '-80'; 703 | dataChannel.send(midiInfo); 704 | } 705 | } 706 | }); 707 | 708 | document.addEventListener('keyup', function(event) { 709 | if (event.repeat == true) { 710 | return; 711 | } 712 | if (event.srcElement.localName == "input") { 713 | return; 714 | } 715 | let midiKeyCode = -1; 716 | switch (event.code) { 717 | case "KeyA": 718 | midiKeyCode = 60; 719 | break; 720 | case "KeyW": 721 | midiKeyCode = 61; 722 | break; 723 | case "KeyS": 724 | midiKeyCode = 62; 725 | break; 726 | case "KeyE": 727 | midiKeyCode = 63; 728 | break; 729 | case "KeyD": 730 | midiKeyCode = 64; 731 | break; 732 | case "KeyF": 733 | midiKeyCode = 65; 734 | break; 735 | case "KeyT": 736 | midiKeyCode = 66; 737 | break; 738 | case "KeyG": 739 | midiKeyCode = 67; 740 | break; 741 | case "KeyY": 742 | midiKeyCode = 68; 743 | break; 744 | case "KeyH": 745 | midiKeyCode = 69; 746 | break; 747 | case "KeyU": 748 | midiKeyCode = 70; 749 | break; 750 | case "KeyJ": 751 | midiKeyCode = 71; 752 | break; 753 | case "KeyK": 754 | midiKeyCode = 72; 755 | break; 756 | } 757 | if (midiKeyCode != -1) { 758 | midiKeyCode += octave*12; 759 | playMidi(ME, 128, midiKeyCode, 0); 760 | if (peerConnected()) { 761 | let midiInfo = '128-' + midiKeyCode + '-0'; 762 | dataChannel.send(midiInfo); 763 | } 764 | } 765 | }); 766 | 767 | function onMidiMessage(message) { 768 | var command = message.data[0]; 769 | var byte1 = message.data[1]; 770 | // a velocity value might not be included with a noteOff command 771 | var byte2 = (message.data.length > 2) ? message.data[2] : 0; 772 | 773 | if (peerConnected()) { 774 | let midiInfo = command + '-' + byte1 + '-' + byte2; 775 | dataChannel.send(midiInfo); 776 | } 777 | playMidi(ME, command, byte1, byte2) 778 | } 779 | 780 | function onLoopMidiMessage(message) { 781 | var command = message.data[0]; 782 | var byte1 = message.data[1]; 783 | // a velocity value might not be included with a noteOff command 784 | var byte2 = (message.data.length > 2) ? message.data[2] : 0; 785 | 786 | if (peerConnected()) { 787 | let midiInfo = command + '-' + byte1 + '-' + byte2; 788 | dataChannel.send("LOOP-" + midiInfo); 789 | } 790 | playMidi(MY_LOOP, command, byte1, byte2) 791 | } 792 | 793 | function pedalOff(who) { 794 | if (who === ME) { 795 | myPedal = false; 796 | let releaseKeys = getAllKeysWhichArentPressed(who); 797 | mySampler.triggerRelease(releaseKeys, Tone.context.currentTime) 798 | } else if (who === THEM) { 799 | theirPedal = false; 800 | let releaseKeys = getAllKeysWhichArentPressed(who); 801 | theirSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 802 | } else if (who === MY_LOOP) { 803 | myLoopPedal = false; 804 | let releaseKeys = getAllKeysWhichArentPressed(who); 805 | myLoopSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 806 | } else if (who === THEIR_LOOP) { 807 | theirLoopPedal = false; 808 | let releaseKeys = getAllKeysWhichArentPressed(who); 809 | theirLoopSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 810 | } 811 | } 812 | 813 | function pedalOn(who) { 814 | if (who === ME) { 815 | myPedal = true; 816 | } else if (who === THEM) { 817 | theirPedal = true; 818 | } else if (who === MY_LOOP) { 819 | myLoopPedal = true; 820 | } else if (who === THEIR_LOOP) { 821 | theirLoopPedal = true; 822 | } 823 | } 824 | 825 | var ALL_KEYS = [] 826 | // A1 to C8 827 | for (let i = 21; i < 108; i++) { 828 | ALL_KEYS.push(getNote(i)); 829 | } 830 | 831 | function getAllKeysWhichArentPressed(who) { 832 | if (who === ME) { 833 | let toReturn = []; 834 | for (let i = 0; i < ALL_KEYS.length; i++) { 835 | if (!myPressedKeys.has(ALL_KEYS[i])) { 836 | toReturn.push(ALL_KEYS[i]); 837 | } 838 | } 839 | return toReturn; 840 | } else if (who === THEM) { 841 | let toReturn = []; 842 | for (let i = 0; i < ALL_KEYS.length; i++) { 843 | if (!theirPressedKeys.has(ALL_KEYS[i])) { 844 | toReturn.push(ALL_KEYS[i]); 845 | } 846 | } 847 | return toReturn; 848 | } else if (who === MY_LOOP) { 849 | let toReturn = []; 850 | for (let i = 0; i < ALL_KEYS.length; i++) { 851 | if (!myLoopPressedKeys.has(ALL_KEYS[i])) { 852 | toReturn.push(ALL_KEYS[i]); 853 | } 854 | } 855 | return toReturn; 856 | } else if (who === THEIR_LOOP) { 857 | let toReturn = []; 858 | for (let i = 0; i < ALL_KEYS.length; i++) { 859 | if (!theirLoopPressedKeys.has(ALL_KEYS[i])) { 860 | toReturn.push(ALL_KEYS[i]); 861 | } 862 | } 863 | return toReturn; 864 | } 865 | } 866 | 867 | function addToLoop(command, byte1, byte2) { 868 | if (loopData.length === 0 && command == 128) { 869 | // if first note in loop is key up, assume we missed keydown and add it automatically 870 | loopData.push({ 871 | time: 0, 872 | command: 144, // keydown 873 | byte1: byte1, 874 | byte2: 80, // assume 80 velocity 875 | }); 876 | } 877 | loopData.push({ 878 | time: Date.now() - loopStartTime, 879 | command: command, 880 | byte1: byte1, 881 | byte2: byte2, 882 | }); 883 | } 884 | 885 | function playPauseLoop() { 886 | if (recording) { 887 | finishLoop(); 888 | } 889 | if (playingLoop == false) { 890 | playingLoop = true; 891 | playLoopOnce(); 892 | loopId = setInterval(playLoopOnce, loopLength); 893 | } else { 894 | clearInterval(loopId); 895 | stopPlayingLoop(); 896 | playingLoop = false; 897 | } 898 | } 899 | 900 | loopTimeoutIds = [] 901 | function playLoopOnce() { 902 | for (let note of loopData) { 903 | noteData = [note.command, note.byte1, note.byte2]; 904 | let message = { 905 | data: noteData, 906 | }; 907 | loopTimeoutIds.push(setTimeout(onLoopMidiMessage, note.time, message)); 908 | } 909 | } 910 | 911 | function stopPlayingLoop() { 912 | for (let id of loopTimeoutIds) { 913 | clearTimeout(id); 914 | } 915 | } 916 | 917 | function beginLoop() { 918 | loopData = []; 919 | console.log("begin loop"); 920 | loopStartTime = Date.now(); 921 | recording = true; 922 | document.getElementById("startLoopButton").disabled = true; 923 | document.getElementById("stopLoopButton").disabled = false; 924 | document.getElementById("playPauseLoopButton").disabled = false; 925 | // automatically set loop sampler things equal to my sampler 926 | //let url = document.getElementById("mysamplerurl").value; 927 | var dropdown = document.getElementById("dropdownsampler"); 928 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 929 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 930 | 931 | let rel = document.getElementById("mysamplerrelease").value; 932 | let gain = document.getElementById("mygain").value; 933 | let decay = document.getElementById("mydecay").value; 934 | changeLoopSampler(url, rel, gain, decay, samplerValue); 935 | if (peerConnected()) { 936 | dataChannel.send("theirLoopSampler " + url + " " + rel + " " + gain + " " + decay); 937 | } 938 | // if pedal is down at start of loop, add to loop 939 | if (myPedal) { 940 | addToLoop(176, 64, 1); 941 | } 942 | } 943 | 944 | function finishLoop() { 945 | console.log("finish loop"); 946 | recording = false; 947 | loopLength = Date.now() - loopStartTime; 948 | document.getElementById("startLoopButton").disabled = false; 949 | document.getElementById("stopLoopButton").disabled = true; 950 | document.getElementById("playPauseLoopButton").disabled = false; 951 | } 952 | 953 | var paused = false; 954 | function toggleVisual() { 955 | if (paused) { 956 | space.resume(); 957 | space2.resume(); 958 | paused = false; 959 | } else { 960 | paused = true; 961 | space.pause(); 962 | space2.pause(); 963 | } 964 | } 965 | 966 | 967 | /**************************************************************************** 968 | * Visualization logic 969 | ****************************************************************************/ 970 | Pts.namespace( window ); 971 | var space = new CanvasSpace("#pts"); 972 | space.setup({ bgcolor: "powderblue" }); 973 | var form = space.getForm(); 974 | 975 | var sound = Sound.from( mySampler, mySampler.context ).analyze(256); 976 | 977 | space.add({ 978 | animate: (time) => { 979 | if (mySampler.context.state === 'suspended') { // mostly for safari 980 | form.fillOnly("#fff").text( [20, 30], "Click anywhere to start" ); 981 | } 982 | 983 | var area = space.size; 984 | var idx = space.pointer.$divide( area ).floor(); 985 | var rect = [idx.$multiply(area), idx.$multiply(area).add(area)]; 986 | 987 | let t1 = sound.timeDomainTo( area, rect[0].$subtract(0, area.y/2) ); 988 | let t2 = t1.map( t => t.$add(0, area.y) ).reverse(); 989 | let freqs = sound.freqDomainTo( [area.x*2, area.y/2], [rect[0].x, 0] ).map( f => [[f.x, rect[0].y+area.y/2-f.y], [f.x, rect[0].y+area.y/2+f.y]] ); 990 | 991 | form.fillOnly("powderblue").polygon( t1.concat(t2) ); 992 | form.strokeOnly("white", Math.ceil(area.x/128) ).lines( freqs ); 993 | }, 994 | action: (type, x, y) => { 995 | if (type === "up") { // for safari 996 | if (mySampler.context.state === 'suspended') { 997 | mySampler.context.resume(); 998 | } 999 | } 1000 | } 1001 | }); 1002 | space.autoResize = false; 1003 | space.play(); 1004 | 1005 | var space2 = new CanvasSpace("#pts2"); 1006 | space2.setup({ bgcolor: "#F9E79F" }); 1007 | var form2 = space2.getForm(); 1008 | 1009 | var sound2 = Sound.from( theirSampler, theirSampler.context ).analyze(256); 1010 | 1011 | space2.add({ 1012 | animate: (time) => { 1013 | if (theirSampler.context.state === 'suspended') { // mostly for safari 1014 | form2.fillOnly("#fff").text( [20, 30], "Click anywhere to start" ); 1015 | } 1016 | 1017 | var area = space2.size; 1018 | var idx = space2.pointer.$divide( area ).floor(); 1019 | var rect = [idx.$multiply(area), idx.$multiply(area).add(area)]; 1020 | 1021 | let t1 = sound2.timeDomainTo( area, rect[0].$subtract(0, area.y/2) ); 1022 | let t2 = t1.map( t => t.$add(0, area.y) ).reverse(); 1023 | let freqs = sound2.freqDomainTo( [area.x*2, area.y/2], [rect[0].x, 0] ).map( f => [[f.x, rect[0].y+area.y/2-f.y], [f.x, rect[0].y+area.y/2+f.y]] ); 1024 | 1025 | form2.fillOnly("#F9E79F").polygon( t1.concat(t2) ); 1026 | form2.strokeOnly("black", Math.ceil(area.x/128) ).lines( freqs ); 1027 | }, 1028 | action: (type, x, y) => { 1029 | if (type === "up") { // for safari 1030 | if (theirSampler.context.state === 'suspended') { 1031 | theirSampler.context.resume(); 1032 | } 1033 | } 1034 | } 1035 | }); 1036 | space2.play(); 1037 | 1038 | function acceptSound() { 1039 | document.cookie = "cookie_soundon=true"; 1040 | document.querySelector('.sound-overlay').classList.add('d-none'); 1041 | Tone.start(); 1042 | } 1043 | 1044 | var diagnosticsOpen = false; 1045 | function toggleDiagnostics() { 1046 | if (diagnosticsOpen) { 1047 | diagnosticsOpen = false; 1048 | document.querySelector('.diagnostics').classList.add('d-none'); 1049 | } else { 1050 | diagnosticsOpen = true; 1051 | document.querySelector('.diagnostics').classList.remove('d-none'); 1052 | } 1053 | } 1054 | -------------------------------------------------------------------------------- /public/js/main_backwards.js: -------------------------------------------------------------------------------- 1 | /***************************************************************************r 2 | * * 3 | * Initial setup 4 | ****************************************************************************/ 5 | 6 | var configuration = { 7 | 'iceServers': [{ 8 | 'urls': 'stun:stun1.l.google.com:19302' 9 | }] 10 | }; 11 | 12 | var sendBtn = document.getElementById('send'); 13 | 14 | // Attach event handlers 15 | sendBtn.addEventListener('click', sendTestMessage); 16 | 17 | // Disable send buttons by default. 18 | sendBtn.disabled = true; 19 | 20 | // Create a random room if not already present in the URL. 21 | var isInitiator; 22 | 23 | var room = window.location.hash.substring(1); 24 | if (!room) { 25 | room = window.location.hash = randomToken(); 26 | } 27 | 28 | /**************************************************************************** 29 | * Signaling server 30 | ****************************************************************************/ 31 | 32 | // Connect to the signaling server 33 | var socket = io.connect(); 34 | 35 | socket.on('created', function(room, clientId) { 36 | console.log('Created room', room, '- my client ID is', clientId); 37 | isInitiator = true; 38 | }); 39 | 40 | socket.on('joined', function(room, clientId) { 41 | console.log('This peer has joined room', room, 'with client ID', clientId); 42 | isInitiator = false; 43 | createPeerConnection(isInitiator, configuration); 44 | }); 45 | 46 | socket.on('ready', function() { 47 | console.log('Socket is ready'); 48 | createPeerConnection(isInitiator, configuration); 49 | }); 50 | 51 | socket.on('full', function(room) { 52 | alert('Room ' + room + ' is full. Try again later.'); 53 | }); 54 | 55 | socket.on('message', function(message) { 56 | //console.log('Client received message:', message); 57 | signalingMessageCallback(message); 58 | }); 59 | 60 | // Joining a room. 61 | socket.emit('create or join', room); 62 | 63 | socket.on('bye', function(room) { 64 | console.log(`Peer leaving room ${room}.`); 65 | sendBtn.disabled = true; 66 | document.getElementById("peer-status").innerHTML = "Lost connection from partner."; 67 | document.getElementById("peer-status").style.color = "#000"; 68 | document.getElementById("ping").innerHTML = ""; 69 | document.getElementById("ping2").innerHTML = ""; 70 | // If peer did not create the room, re-enter to be creator. 71 | if (!isInitiator) { 72 | window.location.reload(); 73 | } 74 | }); 75 | 76 | window.addEventListener('unload', function() { 77 | console.log(`Unloading window. Notifying peers in ${room}.`); 78 | socket.emit('bye', room); 79 | }); 80 | 81 | window.addEventListener('load', function() { 82 | if (document.cookie.indexOf("cookie_soundon=") < 0) { 83 | document.querySelector('.sound-overlay').classList.remove('d-none'); 84 | } 85 | }); 86 | 87 | /** 88 | * Send message to signaling server 89 | */ 90 | function sendMessageToServer(message) { 91 | //console.log('Client sending message to server:', message, ' room:', room); 92 | socket.emit('message', { m: message, r: room }) 93 | } 94 | 95 | /**************************************************************************** 96 | * WebRTC peer connection and data channel 97 | ****************************************************************************/ 98 | 99 | var peerConn; 100 | var dataChannel; 101 | 102 | function signalingMessageCallback(message) { 103 | if (message == null) { 104 | console.log("signalingMessageCallback is ignoring null message"); 105 | return; 106 | } 107 | if (message.type === 'offer') { 108 | console.log('Got offer. Sending answer to peer.'); 109 | peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, 110 | logError); 111 | peerConn.createAnswer(onLocalSessionCreated, logError); 112 | 113 | } else if (message.type === 'answer') { 114 | console.log('Got answer.'); 115 | peerConn.setRemoteDescription(new RTCSessionDescription(message), function() {}, 116 | logError); 117 | 118 | } else if (message.type === 'candidate') { 119 | peerConn.addIceCandidate(new RTCIceCandidate({ 120 | candidate: message.candidate, 121 | sdpMLineIndex: message.label, 122 | sdpMid: message.id 123 | })); 124 | 125 | } 126 | } 127 | 128 | function createPeerConnection(isInitiator, config) { 129 | console.log('Creating Peer connection as initiator?', isInitiator, 'config:', 130 | config); 131 | peerConn = new RTCPeerConnection(config); 132 | 133 | // send any ice candidates to the other peer 134 | peerConn.onicecandidate = function(event) { 135 | //console.log('icecandidate event:', event); 136 | if (event.candidate) { 137 | sendMessageToServer({ 138 | type: 'candidate', 139 | label: event.candidate.sdpMLineIndex, 140 | id: event.candidate.sdpMid, 141 | candidate: event.candidate.candidate 142 | }); 143 | } else { 144 | console.log('End of candidates.'); 145 | } 146 | }; 147 | 148 | if (isInitiator) { 149 | console.log('Creating Data Channel'); 150 | dataChannel = peerConn.createDataChannel('midi-data'); 151 | onDataChannelCreated(dataChannel); 152 | 153 | console.log('Creating an offer'); 154 | peerConn.createOffer() 155 | .then(function(offer) { 156 | return peerConn.setLocalDescription(offer); 157 | }) 158 | .then(() => { 159 | console.log('sending local desc:', peerConn.localDescription); 160 | sendMessageToServer(peerConn.localDescription); 161 | }) 162 | .catch(logError); 163 | 164 | } else { 165 | peerConn.ondatachannel = function(event) { 166 | console.log('ondatachannel:', event.channel); 167 | dataChannel = event.channel; 168 | onDataChannelCreated(dataChannel); 169 | }; 170 | } 171 | } 172 | 173 | function onLocalSessionCreated(desc) { 174 | console.log('local session created:', desc); 175 | peerConn.setLocalDescription(desc).then(function() { 176 | console.log('sending local desc:', peerConn.localDescription); 177 | sendMessageToServer(peerConn.localDescription); 178 | }).catch(logError); 179 | } 180 | 181 | function peerConnected() { 182 | return dataChannel && peerConn.connectionState == "connected"; 183 | } 184 | 185 | var pingTime = 0; 186 | 187 | function sendPing() { 188 | if (peerConnected()) { 189 | dataChannel.send('ping'); 190 | pingTime = Date.now(); 191 | } else { 192 | } 193 | } 194 | 195 | function onDataChannelCreated(channel) { 196 | console.log('onDataChannelCreated:', channel); 197 | 198 | channel.onopen = function() { 199 | console.log('CHANNEL opened!!!'); 200 | document.getElementById("peer-status").innerHTML = "Partner connected."; 201 | document.getElementById("peer-status").style.color = "green"; 202 | sendPing(); 203 | window.setInterval(sendPing, 1000); 204 | sendBtn.disabled = false; 205 | // when connecting, send sampler info in case player changed already 206 | onSetMySamplerButtonPress(); 207 | onSetLoopSamplerButtonPress(); 208 | }; 209 | 210 | channel.onclose = function () { 211 | console.log('Channel closed.'); 212 | sendBtn.disabled = true; 213 | document.getElementById("peer-status").innerHTML = "Lost connection from partner."; 214 | document.getElementById("peer-status").style.color = "#000"; 215 | document.getElementById("ping").innerHTML = ""; 216 | document.getElementById("ping2").innerHTML = ""; 217 | } 218 | 219 | channel.onmessage = function onmessage(event) { 220 | if (typeof event.data === 'string') { 221 | if (event.data == "ping") { 222 | dataChannel.send("pong"); 223 | return; 224 | } 225 | if (event.data == "pong") { 226 | let ping = Date.now() - pingTime; 227 | document.getElementById("ping").innerHTML = ping; 228 | document.getElementById("ping2").innerHTML = Math.floor(ping/2); 229 | return; 230 | } 231 | if (event.data.substring(0, 9) == "mySampler") { 232 | let samplerData = event.data.split(' '); 233 | changeTheirSampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 234 | console.log(event.data); 235 | return; 236 | } 237 | // only you can set your own loop, so we don't need to listen for myLoopSampler 238 | if (event.data.substring(0, 16) == "theirLoopSampler") { 239 | let samplerData = event.data.split(' '); 240 | changeTheirLoopSampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 241 | console.log(event.data); 242 | return; 243 | } 244 | if (event.data.substring(0, 12) == "theirSampler") { 245 | let samplerData = event.data.split(' '); 246 | changeMySampler(samplerData[1], samplerData[2], samplerData[3], samplerData[4], samplerData[5]); 247 | console.log(event.data); 248 | return; 249 | } 250 | console.log(event.data, Date.now()); 251 | let midiData = event.data.split('-'); 252 | if (midiData.length == 3) { 253 | // looks like midi data to me, lets just try to play it 254 | playMidi(THEM, parseInt(midiData[0]), parseInt(midiData[1]), parseInt(midiData[2])); 255 | } 256 | if (midiData.length == 4 && midiData[0] == "LOOP") { 257 | // loop midi data 258 | playMidi(THEIR_LOOP, parseInt(midiData[1]), parseInt(midiData[2]), parseInt(midiData[3])); 259 | } 260 | return; 261 | } 262 | }; 263 | } 264 | 265 | function playTheirMidi(command, byte1, byte2) { 266 | } 267 | 268 | /**************************************************************************** 269 | * MIDI things 270 | ****************************************************************************/ 271 | 272 | var NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] 273 | 274 | // pedal for both samplers 275 | var myPedal = false; 276 | var theirPedal = false; 277 | var myLoopPedal = false; 278 | var theirLoopPedal = false; 279 | 280 | // currently pressed keys for both samplers (used when releasing pedal) 281 | var myPressedKeys = new Set() 282 | var theirPressedKeys = new Set() 283 | var myLoopPressedKeys = new Set() 284 | var theirLoopPressedKeys = new Set() 285 | 286 | const ME = 0; 287 | const THEM = 1; 288 | const MY_LOOP = 2; 289 | const THEIR_LOOP = 3; 290 | 291 | // gain for both samplers 292 | var myGain = 1.0; 293 | var theirGain = 1.0; 294 | var myLoopGain = 1.0; 295 | var theirLoopGain = 1.0; 296 | 297 | Tone.context.latencyHint = "fastest"; 298 | 299 | // octave for computer keyboard entry 300 | var octave = 0; 301 | 302 | // local loop data 303 | document.getElementById("startLoopButton").disabled = false; 304 | document.getElementById("stopLoopButton").disabled = true; 305 | document.getElementById("playPauseLoopButton").disabled = true; 306 | var loopStartTime; 307 | var loopLength; 308 | var recording = false; 309 | var loopData = []; 310 | var playingLoop = false; 311 | var loopId; 312 | 313 | if (navigator.requestMIDIAccess) { 314 | console.log('This browser supports WebMIDI!'); 315 | document.getElementById("browser-status").innerHTML = "Browser supports MIDI"; 316 | document.getElementById("browser-status").style.color = "green"; 317 | } else { 318 | console.log('WebMIDI is not supported in this browser.'); 319 | document.getElementById("browser-status").innerHTML = "No browser support for MIDI. Consider trying Chrome or Edge"; 320 | document.getElementById("browser-status").style.color = "red"; 321 | } 322 | 323 | try { 324 | navigator.requestMIDIAccess() 325 | .then(onMIDISuccess, onMIDIFailure); 326 | } catch (e) { 327 | console.log(e); 328 | } 329 | 330 | const default_reverb = new Tone.Reverb(1.5).toDestination(); 331 | 332 | var mySampler = new Tone.Sampler({ 333 | urls: { 334 | A1: "A1.mp3", 335 | A2: "A2.mp3", 336 | A3: "A3.mp3", 337 | A4: "A4.mp3", 338 | A5: "A5.mp3", 339 | A6: "A6.mp3", 340 | A7: "A7.mp3", 341 | }, 342 | release: 0.6, 343 | baseUrl: "https://tonejs.github.io/audio/salamander/", 344 | }).connect(default_reverb).toDestination(); 345 | 346 | var myLoopSampler = new Tone.Sampler({ 347 | urls: { 348 | A1: "A1.mp3", 349 | A2: "A2.mp3", 350 | A3: "A3.mp3", 351 | A4: "A4.mp3", 352 | A5: "A5.mp3", 353 | A6: "A6.mp3", 354 | A7: "A7.mp3", 355 | }, 356 | release: 0.6, 357 | baseUrl: "https://tonejs.github.io/audio/salamander/", 358 | }).connect(default_reverb).toDestination(); 359 | 360 | var theirLoopSampler = new Tone.Sampler({ 361 | urls: { 362 | A1: "A1.mp3", 363 | A2: "A2.mp3", 364 | A3: "A3.mp3", 365 | A4: "A4.mp3", 366 | A5: "A5.mp3", 367 | A6: "A6.mp3", 368 | A7: "A7.mp3", 369 | }, 370 | release: 0.6, 371 | baseUrl: "https://tonejs.github.io/audio/salamander/", 372 | }).connect(default_reverb).toDestination(); 373 | 374 | var theirSampler = new Tone.Sampler({ 375 | urls: { 376 | A1: "A1.mp3", 377 | A2: "A2.mp3", 378 | A3: "A3.mp3", 379 | A4: "A4.mp3", 380 | A5: "A5.mp3", 381 | A6: "A6.mp3", 382 | A7: "A7.mp3", 383 | }, 384 | release: 0.6, 385 | baseUrl: "https://tonejs.github.io/audio/salamander/", 386 | }).connect(default_reverb).toDestination(); 387 | 388 | 389 | function sendTestMessage() { 390 | if (!dataChannel) { 391 | logError('Connection has not been initiated. ' + 392 | 'Get two peers in the same room first'); 393 | return; 394 | } else if (dataChannel.readyState === 'closed') { 395 | logError('Connection was lost. Peer closed the connection.'); 396 | return; 397 | } 398 | dataChannel.send("Test message"); 399 | sendPing(); 400 | } 401 | 402 | function randomToken() { 403 | return Math.floor((1 + Math.random()) * 1e16).toString(16).substring(8); 404 | } 405 | 406 | function logError(err) { 407 | if (!err) return; 408 | if (typeof err === 'string') { 409 | console.warn(err); 410 | } else { 411 | console.warn(err.toString(), err); 412 | } 413 | } 414 | 415 | function playMidi(who, command, byte1, byte2) { 416 | switch (command) { 417 | case 144: // keyDown 418 | if (byte2 > 0) { 419 | keyDown(who, byte1, byte2); 420 | } else { 421 | keyUp(who, byte1); 422 | } 423 | break; 424 | case 128: // keyUp 425 | keyUp(who, byte1); 426 | break; 427 | case 176: // special command 428 | if (byte1 == 64) { // pedal 429 | if (byte2 == 0) { 430 | pedalOff(who); 431 | } else { 432 | pedalOn(who); 433 | } 434 | } 435 | break; 436 | } 437 | if (recording && who == ME) { 438 | addToLoop(command, byte1, byte2); 439 | } 440 | } 441 | 442 | function onSetMySamplerButtonPress() { 443 | //let url = document.getElementById("mysamplerurl").value; 444 | 445 | var dropdown = document.getElementById("dropdownsampler"); 446 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 447 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 448 | 449 | let rel = document.getElementById("mysamplerrelease").value; 450 | let gain = document.getElementById("mygain").value; 451 | let decay = document.getElementById("mydecay").value; 452 | changeMySampler(url, rel, gain, decay, samplerValue); 453 | if (peerConnected()) { 454 | dataChannel.send("mySampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 455 | } 456 | } 457 | 458 | function onSetLoopSamplerButtonPress() { 459 | var dropdown = document.getElementById("loopdropdownsampler"); 460 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 461 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 462 | 463 | let rel = document.getElementById("loopsamplerrelease").value; 464 | let gain = document.getElementById("loopgain").value; 465 | let decay = document.getElementById("loopdecay").value; 466 | changeLoopSampler(url, rel, gain, decay, samplerValue); 467 | if (peerConnected()) { 468 | dataChannel.send("theirLoopSampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 469 | } 470 | } 471 | 472 | function changeMySampler(url, rel, gain, decay, samplerValue) { 473 | console.log("changeMySampler"); 474 | fetch(url + 'config.json') 475 | .then(response => response.json()) 476 | .then(function (mapping) { 477 | mySampler = new Tone.Sampler({ 478 | urls: mapping, 479 | release: rel, 480 | baseUrl: url, 481 | }).toDestination(); 482 | if (parseFloat(decay) > 0) { 483 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 484 | mySampler.connect(reverb); 485 | } 486 | myGain = parseFloat(gain); 487 | sound = Sound.from( mySampler, mySampler.context ).analyze(256); 488 | //document.getElementById("mysamplerurl").value = url; 489 | document.getElementById("dropdownsampler").value = samplerValue; 490 | document.getElementById("mysamplerrelease").value = rel; 491 | document.getElementById("mygain").value = gain; 492 | document.getElementById("mydecay").value = decay; 493 | }); 494 | } 495 | 496 | function changeLoopSampler(url, rel, gain, decay, samplerValue) { 497 | console.log("changeLoopSampler"); 498 | fetch(url + 'config.json') 499 | .then(response => response.json()) 500 | .then(function (mapping) { 501 | myLoopSampler = new Tone.Sampler({ 502 | urls: mapping, 503 | release: rel, 504 | baseUrl: url, 505 | }).toDestination(); 506 | if (parseFloat(decay) > 0) { 507 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 508 | myLoopSampler.connect(reverb); 509 | } 510 | myLoopGain = parseFloat(gain); 511 | document.getElementById("loopdropdownsampler").value = samplerValue; 512 | document.getElementById("loopsamplerrelease").value = rel; 513 | document.getElementById("loopgain").value = gain; 514 | document.getElementById("loopdecay").value = decay; 515 | }); 516 | } 517 | 518 | function changeTheirLoopSampler(url, rel, gain, decay) { 519 | console.log("changeTheirLoopSampler"); 520 | fetch(url + 'config.json') 521 | .then(response => response.json()) 522 | .then(function (mapping) { 523 | theirLoopSampler = new Tone.Sampler({ 524 | urls: mapping, 525 | release: rel, 526 | baseUrl: url, 527 | }).toDestination(); 528 | if (parseFloat(decay) > 0) { 529 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 530 | theirLoopSampler.connect(reverb); 531 | } 532 | theirLoopGain = parseFloat(gain); 533 | }); 534 | } 535 | 536 | function onSetTheirSamplerButtonPress() { 537 | var dropdown = document.getElementById("theirdropdownsampler"); 538 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 539 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 540 | 541 | let rel = document.getElementById("theirsamplerrelease").value; 542 | let gain = document.getElementById("theirgain").value; 543 | let decay = document.getElementById("theirdecay").value; 544 | changeTheirSampler(url, rel, gain, decay, samplerValue); 545 | if (peerConnected()) { 546 | dataChannel.send("theirSampler " + url + " " + rel + " " + gain + " " + decay + " " + samplerValue); 547 | } 548 | } 549 | 550 | function changeTheirSampler(url, rel, gain, decay, samplerValue) { 551 | console.log("changeTheirSampler"); 552 | fetch(url + 'config.json') 553 | .then(response => response.json()) 554 | .then(function (mapping) { 555 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 556 | theirSampler = new Tone.Sampler({ 557 | urls: mapping, 558 | release: rel, 559 | baseUrl: url, 560 | }).toDestination(); 561 | if (parseFloat(decay) > 0) { 562 | let reverb = new Tone.Reverb(parseFloat(decay)).toDestination(); 563 | theirSampler.connect(reverb); 564 | } 565 | theirGain = parseFloat(gain); 566 | sound2 = Sound.from( theirSampler, theirSampler.context ).analyze(256); 567 | document.getElementById("theirdropdownsampler").value = samplerValue; 568 | document.getElementById("theirsamplerrelease").value = rel; 569 | document.getElementById("theirgain").value = gain; 570 | document.getElementById("theirdecay").value = decay; 571 | }); 572 | } 573 | 574 | function keyDown(who, midiValue, velocity) { 575 | let note = getNote(midiValue); 576 | if (who === ME) { 577 | myPressedKeys.add(note); 578 | mySampler.triggerAttack(note, Tone.context.currentTime, velocity*myGain/120) 579 | } else if (who === THEM) { 580 | theirPressedKeys.add(note); 581 | theirSampler.triggerAttack(note, Tone.context.currentTime, velocity*theirGain/120) 582 | } else if (who === MY_LOOP) { 583 | myLoopPressedKeys.add(note); 584 | myLoopSampler.triggerAttack(note, Tone.context.currentTime, velocity*myLoopGain/120) 585 | } else if (who === THEIR_LOOP) { 586 | theirLoopPressedKeys.add(note); 587 | theirLoopSampler.triggerAttack(note, Tone.context.currentTime, velocity*theirLoopGain/120) 588 | } 589 | } 590 | 591 | function keyUp(who, midiValue) { 592 | let note = getNote(midiValue); 593 | if (who === ME) { 594 | myPressedKeys.delete(note) 595 | if (!myPedal) { 596 | mySampler.triggerRelease(note, Tone.context.currentTime) 597 | } 598 | } else if (who === THEM) { 599 | theirPressedKeys.delete(note) 600 | if (!theirPedal) { 601 | theirSampler.triggerRelease(note, Tone.context.currentTime) 602 | } 603 | } else if (who === MY_LOOP) { 604 | myLoopPressedKeys.delete(note) 605 | if (!myLoopPedal) { 606 | myLoopSampler.triggerRelease(note, Tone.context.currentTime) 607 | } 608 | } else if (who === THEIR_LOOP) { 609 | theirLoopPressedKeys.delete(note) 610 | if (!theirLoopPedal) { 611 | theirLoopSampler.triggerRelease(note, Tone.context.currentTime) 612 | } 613 | } 614 | } 615 | 616 | function getNote(midiValue) { 617 | //let noteLetter = NOTES[midiValue%12]; 618 | //let octave = Math.floor(midiValue/12)-1; 619 | //return noteLetter + octave; 620 | let flippedMidiValue = 128 - midiValue - 4; 621 | let noteLetter = NOTES[flippedMidiValue%12]; 622 | let octave = Math.floor(flippedMidiValue/12)-1; 623 | return noteLetter + octave; 624 | 625 | } 626 | 627 | function onMIDIFailure() { 628 | console.log('Could not access your MIDI devices.'); 629 | } 630 | 631 | function onMIDISuccess(midiAccess) { 632 | console.log(midiAccess); 633 | 634 | var inputs = midiAccess.inputs; 635 | var outputs = midiAccess.outputs; 636 | var deviceInfoMessage = "List of devices: ["; 637 | for (var input of midiAccess.inputs.values()) { 638 | deviceInfoMessage += input.name + ", "; 639 | input.onmidimessage = onMidiMessage; 640 | } 641 | deviceInfoMessage += "]"; 642 | if (inputs.size > 0) { 643 | document.getElementById("midi-status").innerHTML = deviceInfoMessage; 644 | document.getElementById("midi-status").style.color = "green"; 645 | } 646 | } 647 | 648 | document.addEventListener('keydown', function(event) { 649 | if (event.repeat == true) { 650 | return; 651 | } 652 | if (event.srcElement.localName == "input") { 653 | return; 654 | } 655 | let midiKeyCode = -1; 656 | switch (event.code) { 657 | case "KeyA": 658 | midiKeyCode = 60; 659 | break; 660 | case "KeyW": 661 | midiKeyCode = 61; 662 | break; 663 | case "KeyS": 664 | midiKeyCode = 62; 665 | break; 666 | case "KeyE": 667 | midiKeyCode = 63; 668 | break; 669 | case "KeyD": 670 | midiKeyCode = 64; 671 | break; 672 | case "KeyF": 673 | midiKeyCode = 65; 674 | break; 675 | case "KeyT": 676 | midiKeyCode = 66; 677 | break; 678 | case "KeyG": 679 | midiKeyCode = 67; 680 | break; 681 | case "KeyY": 682 | midiKeyCode = 68; 683 | break; 684 | case "KeyH": 685 | midiKeyCode = 69; 686 | break; 687 | case "KeyU": 688 | midiKeyCode = 70; 689 | break; 690 | case "KeyJ": 691 | midiKeyCode = 71; 692 | break; 693 | case "KeyK": 694 | midiKeyCode = 72; 695 | break; 696 | case "KeyZ": 697 | octave--; 698 | break; 699 | case "KeyX": 700 | octave++; 701 | break; 702 | } 703 | if (midiKeyCode != -1) { 704 | midiKeyCode += octave*12; 705 | playMidi(ME, 144, midiKeyCode, 80); 706 | if (peerConnected()) { 707 | let midiInfo = '144-' + midiKeyCode + '-80'; 708 | dataChannel.send(midiInfo); 709 | } 710 | } 711 | }); 712 | 713 | document.addEventListener('keyup', function(event) { 714 | if (event.repeat == true) { 715 | return; 716 | } 717 | if (event.srcElement.localName == "input") { 718 | return; 719 | } 720 | let midiKeyCode = -1; 721 | switch (event.code) { 722 | case "KeyA": 723 | midiKeyCode = 60; 724 | break; 725 | case "KeyW": 726 | midiKeyCode = 61; 727 | break; 728 | case "KeyS": 729 | midiKeyCode = 62; 730 | break; 731 | case "KeyE": 732 | midiKeyCode = 63; 733 | break; 734 | case "KeyD": 735 | midiKeyCode = 64; 736 | break; 737 | case "KeyF": 738 | midiKeyCode = 65; 739 | break; 740 | case "KeyT": 741 | midiKeyCode = 66; 742 | break; 743 | case "KeyG": 744 | midiKeyCode = 67; 745 | break; 746 | case "KeyY": 747 | midiKeyCode = 68; 748 | break; 749 | case "KeyH": 750 | midiKeyCode = 69; 751 | break; 752 | case "KeyU": 753 | midiKeyCode = 70; 754 | break; 755 | case "KeyJ": 756 | midiKeyCode = 71; 757 | break; 758 | case "KeyK": 759 | midiKeyCode = 72; 760 | break; 761 | } 762 | if (midiKeyCode != -1) { 763 | midiKeyCode += octave*12; 764 | playMidi(ME, 128, midiKeyCode, 0); 765 | if (peerConnected()) { 766 | let midiInfo = '128-' + midiKeyCode + '-0'; 767 | dataChannel.send(midiInfo); 768 | } 769 | } 770 | }); 771 | 772 | function onMidiMessage(message) { 773 | var command = message.data[0]; 774 | var byte1 = message.data[1]; 775 | // a velocity value might not be included with a noteOff command 776 | var byte2 = (message.data.length > 2) ? message.data[2] : 0; 777 | 778 | if (peerConnected()) { 779 | let midiInfo = command + '-' + byte1 + '-' + byte2; 780 | dataChannel.send(midiInfo); 781 | } 782 | playMidi(ME, command, byte1, byte2) 783 | } 784 | 785 | function onLoopMidiMessage(message) { 786 | var command = message.data[0]; 787 | var byte1 = message.data[1]; 788 | // a velocity value might not be included with a noteOff command 789 | var byte2 = (message.data.length > 2) ? message.data[2] : 0; 790 | 791 | if (peerConnected()) { 792 | let midiInfo = command + '-' + byte1 + '-' + byte2; 793 | dataChannel.send("LOOP-" + midiInfo); 794 | } 795 | playMidi(MY_LOOP, command, byte1, byte2) 796 | } 797 | 798 | function pedalOff(who) { 799 | if (who === ME) { 800 | myPedal = false; 801 | let releaseKeys = getAllKeysWhichArentPressed(who); 802 | mySampler.triggerRelease(releaseKeys, Tone.context.currentTime) 803 | } else if (who === THEM) { 804 | theirPedal = false; 805 | let releaseKeys = getAllKeysWhichArentPressed(who); 806 | theirSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 807 | } else if (who === MY_LOOP) { 808 | myLoopPedal = false; 809 | let releaseKeys = getAllKeysWhichArentPressed(who); 810 | myLoopSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 811 | } else if (who === THEIR_LOOP) { 812 | theirLoopPedal = false; 813 | let releaseKeys = getAllKeysWhichArentPressed(who); 814 | theirLoopSampler.triggerRelease(releaseKeys, Tone.context.currentTime) 815 | } 816 | } 817 | 818 | function pedalOn(who) { 819 | if (who === ME) { 820 | myPedal = true; 821 | } else if (who === THEM) { 822 | theirPedal = true; 823 | } else if (who === MY_LOOP) { 824 | myLoopPedal = true; 825 | } else if (who === THEIR_LOOP) { 826 | theirLoopPedal = true; 827 | } 828 | } 829 | 830 | var ALL_KEYS = [] 831 | // A1 to C8 832 | for (let i = 21; i < 108; i++) { 833 | ALL_KEYS.push(getNote(i)); 834 | } 835 | 836 | function getAllKeysWhichArentPressed(who) { 837 | if (who === ME) { 838 | let toReturn = []; 839 | for (let i = 0; i < ALL_KEYS.length; i++) { 840 | if (!myPressedKeys.has(ALL_KEYS[i])) { 841 | toReturn.push(ALL_KEYS[i]); 842 | } 843 | } 844 | return toReturn; 845 | } else if (who === THEM) { 846 | let toReturn = []; 847 | for (let i = 0; i < ALL_KEYS.length; i++) { 848 | if (!theirPressedKeys.has(ALL_KEYS[i])) { 849 | toReturn.push(ALL_KEYS[i]); 850 | } 851 | } 852 | return toReturn; 853 | } else if (who === MY_LOOP) { 854 | let toReturn = []; 855 | for (let i = 0; i < ALL_KEYS.length; i++) { 856 | if (!myLoopPressedKeys.has(ALL_KEYS[i])) { 857 | toReturn.push(ALL_KEYS[i]); 858 | } 859 | } 860 | return toReturn; 861 | } else if (who === THEIR_LOOP) { 862 | let toReturn = []; 863 | for (let i = 0; i < ALL_KEYS.length; i++) { 864 | if (!theirLoopPressedKeys.has(ALL_KEYS[i])) { 865 | toReturn.push(ALL_KEYS[i]); 866 | } 867 | } 868 | return toReturn; 869 | } 870 | } 871 | 872 | function addToLoop(command, byte1, byte2) { 873 | if (loopData.length === 0 && command == 128) { 874 | // if first note in loop is key up, assume we missed keydown and add it automatically 875 | loopData.push({ 876 | time: 0, 877 | command: 144, // keydown 878 | byte1: byte1, 879 | byte2: 80, // assume 80 velocity 880 | }); 881 | } 882 | loopData.push({ 883 | time: Date.now() - loopStartTime, 884 | command: command, 885 | byte1: byte1, 886 | byte2: byte2, 887 | }); 888 | } 889 | 890 | function playPauseLoop() { 891 | if (recording) { 892 | finishLoop(); 893 | } 894 | if (playingLoop == false) { 895 | playingLoop = true; 896 | playLoopOnce(); 897 | loopId = setInterval(playLoopOnce, loopLength); 898 | } else { 899 | clearInterval(loopId); 900 | stopPlayingLoop(); 901 | playingLoop = false; 902 | } 903 | } 904 | 905 | loopTimeoutIds = [] 906 | function playLoopOnce() { 907 | for (let note of loopData) { 908 | noteData = [note.command, note.byte1, note.byte2]; 909 | let message = { 910 | data: noteData, 911 | }; 912 | loopTimeoutIds.push(setTimeout(onLoopMidiMessage, note.time, message)); 913 | } 914 | } 915 | 916 | function stopPlayingLoop() { 917 | for (let id of loopTimeoutIds) { 918 | clearTimeout(id); 919 | } 920 | } 921 | 922 | function beginLoop() { 923 | loopData = []; 924 | console.log("begin loop"); 925 | loopStartTime = Date.now(); 926 | recording = true; 927 | document.getElementById("startLoopButton").disabled = true; 928 | document.getElementById("stopLoopButton").disabled = false; 929 | document.getElementById("playPauseLoopButton").disabled = false; 930 | // automatically set loop sampler things equal to my sampler 931 | //let url = document.getElementById("mysamplerurl").value; 932 | var dropdown = document.getElementById("dropdownsampler"); 933 | var samplerValue = dropdown.options[dropdown.selectedIndex].value; 934 | var url = "https://jminjie.github.io/samples/" + samplerValue + '/'; 935 | 936 | let rel = document.getElementById("mysamplerrelease").value; 937 | let gain = document.getElementById("mygain").value; 938 | let decay = document.getElementById("mydecay").value; 939 | changeLoopSampler(url, rel, gain, decay, samplerValue); 940 | if (peerConnected()) { 941 | dataChannel.send("theirLoopSampler " + url + " " + rel + " " + gain + " " + decay); 942 | } 943 | // if pedal is down at start of loop, add to loop 944 | if (myPedal) { 945 | addToLoop(176, 64, 1); 946 | } 947 | } 948 | 949 | function finishLoop() { 950 | console.log("finish loop"); 951 | recording = false; 952 | loopLength = Date.now() - loopStartTime; 953 | document.getElementById("startLoopButton").disabled = false; 954 | document.getElementById("stopLoopButton").disabled = true; 955 | document.getElementById("playPauseLoopButton").disabled = false; 956 | } 957 | 958 | var paused = false; 959 | function toggleVisual() { 960 | if (paused) { 961 | space.resume(); 962 | space2.resume(); 963 | paused = false; 964 | } else { 965 | paused = true; 966 | space.pause(); 967 | space2.pause(); 968 | } 969 | } 970 | 971 | 972 | /**************************************************************************** 973 | * Visualization logic 974 | ****************************************************************************/ 975 | Pts.namespace( window ); 976 | var space = new CanvasSpace("#pts"); 977 | space.setup({ bgcolor: "powderblue" }); 978 | var form = space.getForm(); 979 | 980 | var sound = Sound.from( mySampler, mySampler.context ).analyze(256); 981 | 982 | space.add({ 983 | animate: (time) => { 984 | if (mySampler.context.state === 'suspended') { // mostly for safari 985 | form.fillOnly("#fff").text( [20, 30], "Click anywhere to start" ); 986 | } 987 | 988 | var area = space.size; 989 | var idx = space.pointer.$divide( area ).floor(); 990 | var rect = [idx.$multiply(area), idx.$multiply(area).add(area)]; 991 | 992 | let t1 = sound.timeDomainTo( area, rect[0].$subtract(0, area.y/2) ); 993 | let t2 = t1.map( t => t.$add(0, area.y) ).reverse(); 994 | let freqs = sound.freqDomainTo( [area.x*2, area.y/2], [rect[0].x, 0] ).map( f => [[f.x, rect[0].y+area.y/2-f.y], [f.x, rect[0].y+area.y/2+f.y]] ); 995 | 996 | form.fillOnly("powderblue").polygon( t1.concat(t2) ); 997 | form.strokeOnly("white", Math.ceil(area.x/128) ).lines( freqs ); 998 | }, 999 | action: (type, x, y) => { 1000 | if (type === "up") { // for safari 1001 | if (mySampler.context.state === 'suspended') { 1002 | mySampler.context.resume(); 1003 | } 1004 | } 1005 | } 1006 | }); 1007 | space.autoResize = false; 1008 | space.play(); 1009 | 1010 | var space2 = new CanvasSpace("#pts2"); 1011 | space2.setup({ bgcolor: "#F9E79F" }); 1012 | var form2 = space2.getForm(); 1013 | 1014 | var sound2 = Sound.from( theirSampler, theirSampler.context ).analyze(256); 1015 | 1016 | space2.add({ 1017 | animate: (time) => { 1018 | if (theirSampler.context.state === 'suspended') { // mostly for safari 1019 | form2.fillOnly("#fff").text( [20, 30], "Click anywhere to start" ); 1020 | } 1021 | 1022 | var area = space2.size; 1023 | var idx = space2.pointer.$divide( area ).floor(); 1024 | var rect = [idx.$multiply(area), idx.$multiply(area).add(area)]; 1025 | 1026 | let t1 = sound2.timeDomainTo( area, rect[0].$subtract(0, area.y/2) ); 1027 | let t2 = t1.map( t => t.$add(0, area.y) ).reverse(); 1028 | let freqs = sound2.freqDomainTo( [area.x*2, area.y/2], [rect[0].x, 0] ).map( f => [[f.x, rect[0].y+area.y/2-f.y], [f.x, rect[0].y+area.y/2+f.y]] ); 1029 | 1030 | form2.fillOnly("#F9E79F").polygon( t1.concat(t2) ); 1031 | form2.strokeOnly("black", Math.ceil(area.x/128) ).lines( freqs ); 1032 | }, 1033 | action: (type, x, y) => { 1034 | if (type === "up") { // for safari 1035 | if (theirSampler.context.state === 'suspended') { 1036 | theirSampler.context.resume(); 1037 | } 1038 | } 1039 | } 1040 | }); 1041 | space2.play(); 1042 | 1043 | function acceptSound() { 1044 | document.cookie = "cookie_soundon=true"; 1045 | document.querySelector('.sound-overlay').classList.add('d-none'); 1046 | Tone.start(); 1047 | } 1048 | 1049 | var diagnosticsOpen = false; 1050 | function toggleDiagnostics() { 1051 | if (diagnosticsOpen) { 1052 | diagnosticsOpen = false; 1053 | document.querySelector('.diagnostics').classList.add('d-none'); 1054 | } else { 1055 | diagnosticsOpen = true; 1056 | document.querySelector('.diagnostics').classList.remove('d-none'); 1057 | } 1058 | } 1059 | -------------------------------------------------------------------------------- /public/keyboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/keyboard2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | --------------------------------------------------------------------------------