Voice Intercom Websockets

Creates a voice intercom (aka Walkie Talkie) via a webpage.

The interface is served up as a single page of HTML/CSS/JS in the template node on /intercom

This then connects to a websocket on /socket which takes care of managing the session ids to send any binary messages to all other connected clients but not to yourself.

The page uses the getUserMedia functions of WebRTC to access the microphone and then when the Talk button is held it sends 640byte audio frames over the websocket.

When the page recieves audo frames on its socket it plays them via the speaker.

Because this page uses microphone access it will only work if served over https (or from localhost) The ngrok node is an easy way to get an https url for NodeRED.

This has not been built to be particularly scalable or private its mostly for novelty purposes or as a base to build from.

[{"id":"ac4eab3a772da4ec","type":"tab","label":"Flow 1","disabled":false,"info":""},{"id":"a63b76f70839dc96","type":"websocket in","z":"ac4eab3a772da4ec","name":"","server":"305b5c990ac144fb","client":"","x":150,"y":320,"wires":[["da1729ded5dc2d8e"]]},{"id":"02d4401363eb9ffe","type":"websocket out","z":"ac4eab3a772da4ec","name":"","server":"305b5c990ac144fb","client":"","x":1030,"y":420,"wires":[]},{"id":"da1729ded5dc2d8e","type":"switch","z":"ac4eab3a772da4ec","name":"Binary/Text","property":"payload","propertyType":"msg","rules":[{"t":"istype","v":"string","vt":"string"},{"t":"istype","v":"buffer","vt":"buffer"}],"checkall":"true","repair":false,"outputs":2,"x":350,"y":320,"wires":[["7a4ca05c29d57993"],["067811a23b6ab558"]]},{"id":"7a4ca05c29d57993","type":"json","z":"ac4eab3a772da4ec","name":"","property":"payload","action":"","pretty":false,"x":510,"y":300,"wires":[["bba60d053a1ad990"]]},{"id":"c02a930755b45eba","type":"debug","z":"ac4eab3a772da4ec","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":950,"y":300,"wires":[]},{"id":"067811a23b6ab558","type":"function","z":"ac4eab3a772da4ec","name":"Copy message to all other sessions","func":"var src = msg._session.id\n\nvar sessions = flow.get('sessions');\n\nsessions.forEach(function(s){\n    if (s !=src){\n        msg._session.id = s\n        node.send(msg)\n    }       \n});\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":740,"y":420,"wires":[["02d4401363eb9ffe"]]},{"id":"bba60d053a1ad990","type":"function","z":"ac4eab3a772da4ec","name":"Add Session to flows list","func":"var src = msg._session.id\nvar sessions = flow.get('sessions') || [];\nsessions.push(src)\nflow.set('sessions', sessions)","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":710,"y":300,"wires":[["c02a930755b45eba"]]},{"id":"34db912780da5765","type":"inject","z":"ac4eab3a772da4ec","name":"View Session List","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"sessions","payloadType":"flow","x":160,"y":120,"wires":[["76ba4a5862e0834c"]]},{"id":"76ba4a5862e0834c","type":"debug","z":"ac4eab3a772da4ec","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":370,"y":120,"wires":[]},{"id":"0b0fde7e1f8f9eea","type":"change","z":"ac4eab3a772da4ec","name":"","rules":[{"t":"delete","p":"sessions","pt":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":980,"y":120,"wires":[[]]},{"id":"ea328269a74eae56","type":"inject","z":"ac4eab3a772da4ec","name":"Clear Sessions on Startup","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"true","payloadType":"bool","x":700,"y":120,"wires":[["0b0fde7e1f8f9eea"]]},{"id":"4a14f41bfb7a5ba4","type":"status","z":"ac4eab3a772da4ec","name":"Websocket Status Events","scope":["a63b76f70839dc96"],"x":210,"y":580,"wires":[["c42812838122d473"]]},{"id":"c42812838122d473","type":"switch","z":"ac4eab3a772da4ec","name":"Look for disconnect","property":"status.event","propertyType":"msg","rules":[{"t":"eq","v":"disconnect","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":500,"y":580,"wires":[["c2e8fea0b7c66e9f"]]},{"id":"c2e8fea0b7c66e9f","type":"function","z":"ac4eab3a772da4ec","name":"Remove Session","func":"var src = msg.status._session.id\nvar sessions = flow.get('sessions') || [];\n\nconst index = sessions.indexOf(src);\nif (index > -1) {\n  sessions.splice(index, 1);\n}\n\nflow.set('sessions', sessions)","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":770,"y":580,"wires":[[]]},{"id":"08d1db58fc98eb3a","type":"http in","z":"ac4eab3a772da4ec","name":"","url":"/intercom","method":"get","upload":false,"swaggerDoc":"","x":180,"y":780,"wires":[["67566ad976e34966"]]},{"id":"b66d6e07fc89c848","type":"http response","z":"ac4eab3a772da4ec","name":"","statusCode":"","headers":{},"x":550,"y":780,"wires":[]},{"id":"67566ad976e34966","type":"template","z":"ac4eab3a772da4ec","name":"Web Interface","field":"payload","fieldType":"msg","format":"html","syntax":"plain","template":"<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>WS Intercom</title>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n   <style>\n       body{\n           background: #646970;\n           text-align: center;\n       }\n\n       input {\n           width: 400px;\n           font-size:16px;\n           font-family:monospace;\n       }\n\n       .button {\n           width: 400px;\n\t\t   height: 100px;\n           background-color: #d0d6dd; \n           border: 0px;\n           color: #646970;\n           text-align: center;\n           text-decoration: none;\n           margin-top: 10px;\n           font-size: 20px;\n           font-family:monospace;\n       }\n       h1{\n\t\t   color: #f4f4f4;\n           font-size:30px;\n           font-family:monospace;\n       }\n       h3{\n\t\t   color: #f4f4f4;\n           font-size:24px;\n           font-family:monospace;\n       }\n       </style>\n  </head>\n  <body>\n    <h1>WS Intercom</h1>\n\t<h3>Connection:<span id='conn'>⚪️</span> Tx:<span id='tx'>⚪️</span></h3>\n\t<button class=\"button\" id=\"con_btn\", onclick='connect()' style=\"height:50px\">Connect</button>\n\t<br>\n\t<button  class=\"button\" id=\"ptt_btn\" style=\"height:200px\">Talk</button>   \n  <script>\n    var wsurl;\n    var headerdata;\n\t  var ptt_btn=document.getElementById(\"ptt_btn\");\n\t  var con_btn=document.getElementById(\"con_btn\");\n    var ptt=false;\n\t  var status_off = '⚪️';\n\t  var status_on = '🔵';\n\t  var status_tx = '🔴';\n      var AudioContext = window.AudioContext || window.webkitAudioContext\n      var context = new AudioContext()\n\t  var time;\n\t  var ws;\n\t  \n\t  ptt_btn.disabled = true; \n\t  ptt_btn.onmousedown = function(e){\n\t  ptt=true;\n\t\tdocument.getElementById(\"tx\").innerHTML=status_tx;\n\t  } \n\t  ptt_btn.onmouseup = function(e){\n\t  \tptt=false;\n\t\tdocument.getElementById(\"tx\").innerHTML=status_off;\n\t  }\n\t\t\t\t\t\n      \n    function extend(obj, src) {\n    \t    for (var key in src) {\n    \t        if (src.hasOwnProperty(key)) obj[key] = src[key];\n    \t    }\n    \t    return obj;\n    \t}\n    \n\t  function connect(){\n\t      if (window.location.protocol == 'https:'){\n\t          wsurl = 'wss://'+window.location.host+'/socket';\n\t      } else{\n\t          wsurl = 'ws://'+window.location.host+'/socket';\n\t      }\n\n      console.log(wsurl);\n\t\t  ws = new WebSocket(wsurl, \"WSBRIDGE\")\n\t      ws.binaryType = 'arraybuffer';\n\t\t  time = 0\n\t\t  con_btn.innerText = 'Disconnect';\n\t\t  con_btn.onclick = function () { ws.close()};\n\t\t  ptt_btn.disabled = false; \n\t\t\n\t\t  ws.onopen = function(){\n\t\t  document.getElementById(\"conn\").innerHTML=status_on;\n\t\t  var evt = {\"event\": \"websocket:connected\", \"content-type\" : \"audio/l16;rate=16000\"};\n   \n  \t\t  ws.send(JSON.stringify(evt));\n\t\t\t  console.log(\"Sent: \"+JSON.stringify(evt));\n\t\t\t  console.log('connected');\n\t\t\t\n\t\t  }\n\t\t  ws.onclose = function(){\n\t\t\t   document.getElementById(\"conn\").innerHTML=status_off;\n\t\t\t\tconsole.log('disconnected');\n\t\t\t\tcon_btn.innerText = 'Connect';\n\t\t\t\tcon_btn.onclick = function () { connect()};\n\t\t\t\tptt_btn.disabled = true; \n\t\t  }\n\t\t  \n\t      ws.onmessage = function(event){\n          if(event.data instanceof ArrayBuffer) {\n\t          time = Math.max(context.currentTime, time)\n\t          var input = new Int16Array(event.data)\n\t            if(input.length) {\n\t              var buffer = context.createBuffer(1, input.length, 16000)\n\t              var data = buffer.getChannelData(0)\n\t              for (var i = 0; i < data.length; i++) {\n\t                data[i] = input[i] / 32767\n\t              }\n\t              var source = context.createBufferSource()\n\t              source.buffer = buffer\n\t              source.connect(context.destination)\n\t              source.start(time += buffer.duration)\n\t            }\n\t          } else {\n\t            console.log(\"Recieved: \"+ event.data);\n\t          }\n          }  \n\t }\n\t \n\n      navigator.mediaDevices.getUserMedia({\n        video: false,\n        audio: true\n      })\n\t  .then( stream => {\n        var source = context.createMediaStreamSource(stream)\n        var processor = context.createScriptProcessor(1024, 1, 1)\n        var downsampled = new Int16Array(2048)\n        var downsample_offset = 0\n\n        function process_samples(){\n          while(downsample_offset > 320) {\n            var output = downsampled.slice(0, 320)\n            downsampled.copyWithin(0, 320)\n            downsample_offset -= 320\n\t\t\tif(ptt == true) {\n              ws.send(output.buffer)\n            }\n          }\n        }\n        var sampleRatio = context.sampleRate / 16000\n        processor.onaudioprocess = (audioProcessingEvent) => {\n          var inputBuffer = audioProcessingEvent.inputBuffer\n          var outputBuffer = audioProcessingEvent.outputBuffer\n          var inputData = inputBuffer.getChannelData(0)\n          var outputData = outputBuffer.getChannelData(0)\n          for (var i = 0; i < inputData.length; i += sampleRatio) {\n            var sidx = Math.floor(i)\n            var tidx = Math.floor(i/sampleRatio)\n            downsampled[downsample_offset + tidx] = inputData[sidx] * 32767\n          }\n          downsample_offset += ~~(inputData.length/sampleRatio)\n          if(downsample_offset > 320) {\n            process_samples()\n          }\n          for (var sample = 0; sample < inputBuffer.length; sample++) {\n            // Silence the output\n            outputData[sample] = 0\n          }\n        }\n        source.connect(processor)\n        processor.connect(context.destination)\n      })\n    </script>\n  </body>\n</html>\n","output":"str","x":380,"y":780,"wires":[["b66d6e07fc89c848"]]},{"id":"305b5c990ac144fb","type":"websocket-listener","path":"/socket","wholemsg":"false"}]

Flow Info

Created 3 years, 5 months ago
Rating: 5 1

Owner

Actions

Rate:

Node Types

Core
  • change (x1)
  • debug (x2)
  • function (x3)
  • http in (x1)
  • http response (x1)
  • inject (x2)
  • json (x1)
  • status (x1)
  • switch (x2)
  • template (x1)
  • websocket in (x1)
  • websocket out (x1)
  • websocket-listener (x1)
Other
  • tab (x1)

Tags

  • walkie-talkie
  • intercom
  • voice
  • webrtc
  • websocket
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option