├── Procfile ├── setup.cfg ├── requirements.txt ├── .gitignore ├── static ├── images │ ├── dark-mosaic.png │ ├── grid-pattern.png │ ├── inspiration-geometry.png │ └── pattern-pentagon-fade.png ├── env_errors.html ├── css │ └── style.css └── index.html ├── example_creds.py ├── tests └── audiosocket_test.py ├── creds.py ├── LICENSE ├── README.md └── server.py /Procfile: -------------------------------------------------------------------------------- 1 | web: ./server.py -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nexmo==1.4.0 2 | tornado==4.4.2 3 | phonenumbers==7.7.5 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | __pycache__ 4 | configuration 5 | private.key 6 | venv 7 | -------------------------------------------------------------------------------- /static/images/dark-mosaic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexmo-community/audiosocket-demo/HEAD/static/images/dark-mosaic.png -------------------------------------------------------------------------------- /static/images/grid-pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexmo-community/audiosocket-demo/HEAD/static/images/grid-pattern.png -------------------------------------------------------------------------------- /static/images/inspiration-geometry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexmo-community/audiosocket-demo/HEAD/static/images/inspiration-geometry.png -------------------------------------------------------------------------------- /static/images/pattern-pentagon-fade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexmo-community/audiosocket-demo/HEAD/static/images/pattern-pentagon-fade.png -------------------------------------------------------------------------------- /example_creds.py: -------------------------------------------------------------------------------- 1 | 2 | API_KEY= 'XXX' 3 | API_SECRET = 'XXX' 4 | APP_ID = 'XXX-XXX-XXX' 5 | PRIVATE_KEY_PATH = './private.key' 6 | with open(PRIVATE_KEY_PATH, "r") as kf: 7 | PRIVATE_KEY = kf.read() -------------------------------------------------------------------------------- /tests/audiosocket_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # This isn't very nice: 4 | sys.path.insert(0, '.') 5 | 6 | import server 7 | 8 | 9 | def test_format_number(): 10 | assert server.format_number('447700900704') == '07700 900704' 11 | -------------------------------------------------------------------------------- /static/env_errors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Environment Errors 6 | 7 | 8 |

Environment Errors

9 |

The following environment variables are missing:

10 | 15 | 16 | -------------------------------------------------------------------------------- /creds.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | 5 | class Config(object): 6 | def __init__(self): 7 | self.missing_keys = [] 8 | self.api_key = self._load('API_KEY') 9 | self.api_secret = self._load('API_SECRET') 10 | self.app_id = self._load('APP_ID') 11 | self.private_key = self._load('PRIVATE_KEY') 12 | self.phone_number = self._load('PHONE_NUMBER') 13 | self.host = self._load('HOST') 14 | self.port = self._load('PORT', 8000) 15 | 16 | def _load(self, key, default=None): 17 | val = os.getenv(key, default) 18 | if val is None: 19 | self.missing_keys.append(key) 20 | logging.error("Missing environment variable %s", key) 21 | return val 22 | 23 | @property 24 | def fully_configured(self): 25 | return not self.missing_keys 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sam Machin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | * { -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; } 2 | 3 | html { 4 | height: 100%; 5 | } 6 | body { 7 | color: #fff; 8 | margin: 0; 9 | padding: 2em 0 2em; 10 | font-family: 'Noto Sans', sans-serif; 11 | background: #517fa4; 12 | background: -webkit-linear-gradient(to bottom, #516395 , #020527); 13 | background: url(../images/pattern-pentagon-fade.png) left top repeat-x, 14 | linear-gradient(to bottom, #8E54E9 , #020527); 15 | position: relative; 16 | } 17 | .main { 18 | width: 100%; 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | } 23 | h1, h2, h3 { 24 | margin: 0; 25 | text-rendering: optimizeLegibility; 26 | } 27 | header { 28 | margin: 1em auto; 29 | text-align: center; 30 | z-index: 1; 31 | } 32 | header h1 { 33 | font-family: 'Alfa Slab One'; 34 | font-size: 2.6em; 35 | font-weight: normal; 36 | } 37 | canvas { 38 | width: 100%; 39 | background: transparent; 40 | } 41 | footer { 42 | position: fixed; 43 | bottom: 1em; 44 | right: 1em; 45 | opacity: 0.75; 46 | } 47 | footer a { 48 | color: pink; 49 | } 50 | input { 51 | display: block; 52 | -webkit-appearance: none; 53 | -moz-appearance: none; 54 | border: none; 55 | border-radius: 4px; 56 | color: #333; 57 | font-size: 1.1em; 58 | } 59 | button { 60 | display: block; 61 | -webkit-appearance: none; 62 | -moz-appearance: none; 63 | background: transparent; 64 | color: #fff; 65 | border: 4px solid #fff; 66 | border-radius: 50%; 67 | width: 200px; 68 | height: 200px; 69 | font-size: 140px; 70 | opacity: 0.4; 71 | outline: 0; 72 | 73 | position: absolute; 74 | top: calc(50% - 50px); 75 | left: calc(50% - 100px); 76 | } 77 | button:hover { 78 | opacity: 0.8; 79 | } 80 | button:active { 81 | text-shadow: 0 0 30px rgba(255, 255, 255, 0.8); 82 | } 83 | .active header > p, .active button { 84 | display: none; 85 | } 86 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Audio Socket Visualisation 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Call {{ phone_number }}

14 |

Then click the Connect button to start.

15 |
16 |
17 | 18 | 19 | 20 |
21 | 22 | 25 | 26 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audiosocket Demo 2 | 3 | An app to demonstrate a Web browser playing audio from a conference using the 4 | the Nexmo Voice API WebSockets feature and the browser Web Audio API 5 | 6 | # Installation 7 | 8 | You'll need Python 2.7, and we recommend you install the audiosocket demo inside 9 | a python virtualenv. You may also need header files for Python and OpenSSL, 10 | depending on your operating system. The instructions below are for Ubuntu 14.04. 11 | 12 | ```bash 13 | sudo apt-get install -y python-pip python-dev libssl-dev 14 | pip install --upgrade virtualenv 15 | virtualenv venv 16 | source venv/bin/activate 17 | pip install -r requirements.txt 18 | ``` 19 | 20 | # Configuration 21 | 22 | The Audiosocket server is a [12-factor app](https://12factor.net/) so it can be 23 | easily deployed via Heroku or Docker. This means it's configured using 24 | environment variables. The following configuration values are available: 25 | 26 | | Environment Variable | Required? | Description | 27 | | -------------------- | --------- | ----------- | 28 | | API_KEY | Yes | Your Nexmo API key (Get from the [Nexmo Dashboard](https://dashboard.nexmo.com/settings)) | 29 | | API_SECRET | Yes | Your Nexmo API secret (Get from the [Nexmo Dashboard](https://dashboard.nexmo.com/settings)) | 30 | | APP_ID | Yes | The id generated when you created your Nexmo application. | 31 | | PRIVATE_KEY | Yes | The private key generated when you created your Nexmo application. | 32 | | PHONE_NUMBER | Yes | The Nexmo number associated with the application. | 33 | | HOST | Yes | The hostname through which Nexmo can contact your server. (If you are using ngrok, this will look like `ABC123.ngrok.com`) 34 | | PORT | No | The port the Audiosocket server will bind to (Default: 8000) | 35 | 36 | ## Configuring envdir 37 | 38 | You can use [Foreman](https://github.com/ddollar/foreman) or [Honcho](https://honcho.readthedocs.io/en/latest/), but we're going to 39 | use [envdir](https://pypi.python.org/pypi/envdir) because it supports multi-line 40 | values, and we need to supply a private key, which is quite long. 41 | 42 | `envdir` is configured by creating a directory which will contain one file per 43 | variable. The name of each file is the name of the variable, and the contents 44 | of the file provides the value for that environment variable. 45 | 46 | ```bash 47 | pip install envdir 48 | mkdir config # We'll store our config in here 49 | ``` 50 | 51 | If you haven't already, create a Nexmo account, and then go to [the dashboard](https://dashboard.nexmo.com/settings). At the bottom of the settings 52 | page, you should see your API key and API secret. Paste each of these into 53 | files respectively called `config/API_KEY` and `config/API_SECRET`. 54 | 55 | ## Hostname & Port 56 | 57 | Your audiosocket server needs to be available on a publicly hosted server. If 58 | you want to run it locally, we recommend running [ngrok](https://ngrok.com/) to 59 | create a publicly addressable tunnel to your computer. 60 | 61 | Whatever public hostname you have, you should enter it into `config/HOST`. 62 | You'll also need to know this hostname for the next step, creating a Nexmo 63 | application. 64 | 65 | `echo 'myhostname.example.com' > config/HOST'` 66 | 67 | The `PORT` configuration variable is only required if you don't want to host on 68 | port 8000. If you're running ngrok and you're not using port 8000 for anything 69 | else, just run `ngrok http 8000` to tunnel to your Audiosocket service. 70 | 71 | ## Creating an application and adding a phone number 72 | 73 | Use the [Nexmo command-line tool](https://github.com/Nexmo/nexmo-cli) to create 74 | a new application and associate it with your server (substitute YOUR-HOSTNAME 75 | with the hostname you've put in your `HOST` config file): 76 | 77 | ```bash 78 | nexmo app:create "Audiosocket Demo" "https://YOUR-HOSTNAME/ncco" "https://YOUR-HOSTNAME/event" 79 | ``` 80 | 81 | If it's successful, the `nexmo` tool will print out the new app ID and a 82 | private key. Put these, respectively in `config/APP_ID` and 83 | `config/PRIVATE_KEY`. 84 | 85 | If you need to, find and buy a number: 86 | 87 | ```bash 88 | # Skip the first 2 steps if you already have a Nexmo number to use. 89 | 90 | # Replace GB with your country-code: 91 | nexmo number:search GB —voice 92 | 93 | # Find a number you like, then buy it: 94 | nexmo number:buy [NUMBER] 95 | 96 | # Associate the number with your app-id: 97 | nexmo link:app [NUMBER] [APPID] 98 | ``` 99 | 100 | Paste the phone number into `config/PHONE_NUMBER`. 101 | 102 | At the end of this, your config directory should look something like this: 103 | 104 | ```text 105 | config/ 106 | ├── API_KEY 107 | ├── API_SECRET 108 | ├── APP_ID 109 | ├── HOST 110 | ├── PHONE_NUMBER 111 | └── PRIVATE_KEY 112 | ``` 113 | 114 | Now you can run the audiosocket service with: 115 | 116 | ```bash 117 | envdir config ./venv/python server.py 118 | ``` 119 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import logging 5 | import os.path 6 | 7 | import nexmo 8 | import phonenumbers 9 | import tornado.httpserver 10 | import tornado.ioloop 11 | import tornado.web 12 | import tornado.websocket 13 | 14 | from creds import Config 15 | 16 | WAV_HEADER = 'RIFF$\xe2\x04\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x80>' \ 17 | '\x00\x00\x00}\x00\x00\x02\x00\x10\x00data\x00\xe2\x04\x00' 18 | 19 | 20 | CONFIG = Config() 21 | 22 | 23 | class State(object): 24 | def __init__(self): 25 | # Browser websocket connections for receiving the binary audio data: 26 | self.clients = [] 27 | # Browser websocket connections for receiving the event data: 28 | self.eventclients = [] 29 | self.payload = None # The buffered PCM frames into 200ms WAV file 30 | self.count = 0 # How many PCM frames I have in the buffer 31 | self.vapi_call_uuid = None 32 | self.vapi_connected = False 33 | 34 | def buffer(self, data): 35 | print 'buffering:', len(data) 36 | if self.count == 0: 37 | print 'initial batch' 38 | self.payload = WAV_HEADER + data 39 | self.count += 1 40 | elif self.count == 9: 41 | print 'broadcasting' 42 | self.payload += data 43 | self.broadcast(self.payload) 44 | self.count = 0 45 | self.payload = None 46 | else: 47 | self.payload += data 48 | self.count += 1 49 | 50 | def broadcast(self, payload): 51 | # print "Sending {} bytes".format(str(len(payload))) 52 | for conn in self.clients: 53 | conn.write_message(payload, binary=True) 54 | 55 | def broadcast_event(self, event): 56 | print "Sending Event {}".format(event) 57 | for conn in self.eventclients: 58 | conn.write_message(event) 59 | 60 | def process_event(self, event): 61 | logging.debug("PROCESSING EVENT: %s", event) 62 | if event['direction'] == "outbound" and event['status'] == "answered": 63 | logging.debug("Setting call UUID to: %s", event['uuid']) 64 | self.vapi_call_uuid = event['uuid'] 65 | print "VAPI CALL ID SET AS {}".format(self.vapi_call_uuid) 66 | return True 67 | 68 | def check_clients(self): 69 | print "VAPI Connected: " + str(self.vapi_connected) 70 | logging.debug("Clients: %s, Connected: %s", self.clients, self.vapi_connected) 71 | if len(self.clients) == 1 and not self.vapi_connected: 72 | self.connect_vapi() 73 | elif len(self.clients) == 0 and self.vapi_connected: 74 | self.disconnect_vapi() 75 | else: 76 | return True 77 | 78 | def connect_vapi(self): 79 | logging.info("Instructing VAPI to connect") 80 | response = client.create_call({'to': [{ 81 | "type": "websocket", 82 | "uri": "ws://{host}/socket".format(host=CONFIG.host), 83 | "content-type": "audio/l16;rate=16000", 84 | "headers": { 85 | "app": "audiosocket" 86 | } 87 | }], 88 | 'from': {'type': 'phone', 'number': CONFIG.phone_number}, 89 | 'answer_url': ['https://{host}/ncco'.format(host=CONFIG.host)]}) 90 | logging.debug(repr(response)) 91 | self.vapi_connected = True 92 | return True 93 | 94 | def disconnect_vapi(self): 95 | client.update_call(self.vapi_call_uuid, action='hangup') 96 | self.vapi_connected = False 97 | return True 98 | 99 | 100 | state = State() 101 | 102 | 103 | class MainHandler(tornado.web.RequestHandler): 104 | def get(self): 105 | self.render("static/index.html", 106 | phone_number=format_number(CONFIG.phone_number), 107 | host=CONFIG.host) 108 | 109 | 110 | class EnvErrorsHandler(tornado.web.RequestHandler): 111 | def get(self): 112 | self.render("static/env_errors.html", missing_envs=CONFIG.missing_keys) 113 | 114 | 115 | class EventHandler(tornado.web.RequestHandler): 116 | def post(self): 117 | event = json.loads(self.request.body) 118 | print "EVENT RECEIVED {}".format(json.dumps(event)) 119 | state.process_event(event) 120 | state.broadcast_event(event) 121 | self.set_status(204) 122 | 123 | 124 | class NCCOHandler(tornado.web.RequestHandler): 125 | def get(self): 126 | self.set_header('Content-Type', 'application/json') 127 | self.write(json.dumps([ 128 | { 129 | "action": "talk", 130 | "text": "Connecting to Audio Socket Conference", 131 | }, 132 | { 133 | "action": "conversation", 134 | "name": "audiosocket", 135 | "eventUrl": ["https://{host}/event".format(host=CONFIG.host)], 136 | } 137 | ])) 138 | 139 | 140 | class ServerWSHandler(tornado.websocket.WebSocketHandler): 141 | connections = [] 142 | 143 | def open(self): 144 | print("VAPI Client Connected") 145 | self.connections.append(self) 146 | self.write_message('00000000', binary=True) 147 | 148 | def on_message(self, message): 149 | if type(message) == str: 150 | # print("Binary Message received {}".format(str(len(message)))) 151 | self.write_message(message, binary=True) 152 | state.buffer(message) 153 | else: 154 | print(message) 155 | self.write_message('ok') 156 | 157 | def on_close(self): 158 | print("VAPI Client Disconnected") 159 | self.connections.remove(self) 160 | 161 | 162 | class ClientWSHandler(tornado.websocket.WebSocketHandler): 163 | def open(self): 164 | print("Browser Client Connected") 165 | state.clients.append(self) 166 | state.check_clients() 167 | 168 | def on_message(self, message): 169 | print("Browser Client Message Received") 170 | 171 | def on_close(self): 172 | print("Browser Client Disconnected") 173 | state.clients.remove(self) 174 | state.check_clients() 175 | 176 | 177 | class ClientEventWSHandler(tornado.websocket.WebSocketHandler): 178 | def open(self): 179 | print("Browser Client Connected") 180 | state.eventclients.append(self) 181 | 182 | def on_message(self, message): 183 | print("Browser Client Message Received") 184 | 185 | def on_close(self): 186 | print("Browser Client Disconnected") 187 | state.eventclients.remove(self) 188 | 189 | 190 | def format_number(number): 191 | if not number.startswith("+"): 192 | number = "+" + number 193 | return phonenumbers.format_number( 194 | phonenumbers.parse(number, None), 195 | phonenumbers.PhoneNumberFormat.NATIONAL) 196 | 197 | 198 | if CONFIG.fully_configured: 199 | client = nexmo.Client( 200 | key=CONFIG.api_key, 201 | secret=CONFIG.api_secret, 202 | application_id=CONFIG.app_id, 203 | private_key=CONFIG.private_key, 204 | ) 205 | 206 | # The Server Config 207 | static_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 208 | 'static/') 209 | print static_path 210 | application = tornado.web.Application([ 211 | (r"/", MainHandler), 212 | (r"/event", EventHandler), 213 | (r"/ncco", NCCOHandler), 214 | (r'/socket', ServerWSHandler), 215 | (r'/browser', ClientWSHandler), 216 | (r'/browserevent', ClientEventWSHandler), 217 | (r'/s/(.*)', tornado.web.StaticFileHandler, {'path': static_path}) 218 | ]) 219 | else: 220 | application = tornado.web.Application([ 221 | (r"/", EnvErrorsHandler), 222 | ]) 223 | 224 | # Running It 225 | if __name__ == "__main__": 226 | logging.basicConfig(level=logging.INFO) 227 | logging.getLogger().setLevel(logging.DEBUG) 228 | http_server = tornado.httpserver.HTTPServer(application) 229 | http_server.listen(CONFIG.port) 230 | tornado.ioloop.IOLoop.instance().start() 231 | --------------------------------------------------------------------------------