audio received from the server currently (requires a demo-instruct.wav file), but should have ICE working (cross nat), despite the fact that aiortc doesn't follow the WebRTC API for Ice...main
| @@ -5,3 +5,4 @@ node_modules | |||||
| yarn-error.log | yarn-error.log | ||||
| yarn.lock | yarn.lock | ||||
| .DS_Store | .DS_Store | ||||
| server/p | |||||
| @@ -11,11 +11,14 @@ | |||||
| </head> | </head> | ||||
| <body> | <body> | ||||
| <p></p> | |||||
| <p> | |||||
| <div id="constatus">undefined</div> | |||||
| <audio id="audioSink" autoplay></audio> | |||||
| </p> | |||||
| <script type="text/javascript" src="jamming.js"></script> | <script type="text/javascript" src="jamming.js"></script> | ||||
| <script type="text/javascript"> | <script type="text/javascript"> | ||||
| runPage() | |||||
| //runPage() | |||||
| /* parameters */ | /* parameters */ | ||||
| var params = { | var params = { | ||||
| @@ -25,7 +28,7 @@ var params = { | |||||
| window.AudioContext = window.AudioContext || window.webkitAudioContext; | window.AudioContext = window.AudioContext || window.webkitAudioContext; | ||||
| var audioContext = new AudioContext(); | |||||
| //var audioContext = new AudioContext(); | |||||
| recconf = { | recconf = { | ||||
| samplerRate: 8000, | samplerRate: 8000, | ||||
| @@ -39,8 +39,10 @@ | |||||
| "mocha": "^7.1.1", | "mocha": "^7.1.1", | ||||
| "nyc": "^15.0.1", | "nyc": "^15.0.1", | ||||
| "sinon": "^9.0.2", | "sinon": "^9.0.2", | ||||
| "uuid": "^7.0.3", | |||||
| "webpack": "^4.42.1", | "webpack": "^4.42.1", | ||||
| "webpack-cli": "^3.3.11" | |||||
| "webpack-cli": "^3.3.11", | |||||
| "websocket-as-promised": "^1.0.1" | |||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "inline-worker": "https://github.com/mohayonao/inline-worker.git#7014cd64c3cd6eb884f6743aad682a995e262bb9" | "inline-worker": "https://github.com/mohayonao/inline-worker.git#7014cd64c3cd6eb884f6743aad682a995e262bb9" | ||||
| @@ -0,0 +1,13 @@ | |||||
| VIRTUALENV ?= virtualenv | |||||
| VRITUALENVARGS = | |||||
| FILES=server.py | |||||
| test: | |||||
| (echo $(FILES) | entr sh -c 'make test-noentr | |||||
| test-noentr: | |||||
| python -m coverage run -m unittest server && coverage report --omit=p/\* -m -i | |||||
| env: | |||||
| ($(VIRTUALENV) $(VIRTUALENVARGS) p && . ./p/bin/activate && pip install -r requirements.txt) | |||||
| @@ -0,0 +1,2 @@ | |||||
| git+https://github.com/aiortc/aiortc.git#egg=aiortc | |||||
| aiohttp | |||||
| @@ -0,0 +1,159 @@ | |||||
| import aiohttp | |||||
| import json | |||||
| import logging | |||||
| import os.path | |||||
| import uuid | |||||
| from aiohttp import web | |||||
| from aiortc import RTCPeerConnection, RTCIceCandidate, RTCSessionDescription | |||||
| from aiortc.contrib.media import MediaPlayer | |||||
| logger = logging.getLogger('pc') | |||||
| logger.setLevel(logging.INFO) | |||||
| # Implement https://w3c.github.io/webrtc-pc/#constructor-0 per the | |||||
| # spec. | |||||
| from aioice.candidate import Candidate | |||||
| from aiortc.rtcicetransport import candidate_from_aioice | |||||
| def RealRTCIceCandidate(candidateInitDict): | |||||
| candpref = 'candidate:' | |||||
| candstr = candidateInitDict['candidate'] | |||||
| if not candstr.startswith(candpref): | |||||
| raise ValueError('does not start with proper string') | |||||
| candstr = candstr[len(candpref):] | |||||
| cand = Candidate.from_sdp(candstr) | |||||
| ric = candidate_from_aioice(cand) | |||||
| ric.sdpMid = candidateInitDict['sdpMid'] | |||||
| ric.sdpMLineIndex = candidateInitDict['sdpMLineIndex'] | |||||
| # XXX - exists as part of RTCIceParameters | |||||
| #ric.usernameFragment = candidateInitDict['usernameFragment'] | |||||
| return ric | |||||
| class AudioMixer(object): | |||||
| @property | |||||
| def audio(self): | |||||
| '''The output audio track for this mixing.''' | |||||
| def addTrack(self, track): | |||||
| '''Add an import track that will be mixed with | |||||
| the other tracks.''' | |||||
| mixer = AudioMixer() | |||||
| pcs = set() | |||||
| shutdown = False | |||||
| ROOT = os.path.dirname(__file__) | |||||
| async def index(request): | |||||
| content = open(os.path.join(ROOT, '..', 'dist', 'audiotest.html'), 'r').read() | |||||
| return web.Response(content_type='text/html', text=content) | |||||
| async def jammingjs(request): | |||||
| content = open(os.path.join(ROOT, '..', 'dist', 'jamming.js'), 'r').read() | |||||
| return web.Response(content_type='application/javascript', text=content) | |||||
| # XXX - update hander to pass uuid and meeting id in the url | |||||
| async def ws_handler(request): | |||||
| ws = web.WebSocketResponse() | |||||
| await ws.prepare(request) | |||||
| pc_id = str(uuid.uuid4()) | |||||
| def log_info(msg, *args): | |||||
| #print(repr(msg), repr(args)) | |||||
| # shouldn't be warning, but can't get logging working otherwise | |||||
| logger.warning(pc_id + " " + msg, *args) | |||||
| log_info("Created for %s", request.remote) | |||||
| doexit = False | |||||
| async for msg in ws: | |||||
| if doexit: | |||||
| break | |||||
| if msg.type == aiohttp.WSMsgType.TEXT: | |||||
| data = json.loads(msg.data) | |||||
| log_info('got msg: %s', repr(data)) | |||||
| if 'sdp' in data: | |||||
| offer = RTCSessionDescription( | |||||
| sdp=data['sdp'], type=data['type']) | |||||
| elif 'ice' in data: | |||||
| pc.addIceCandidate(RealRTCIceCandidate(data['ice'])) | |||||
| continue | |||||
| pc = RTCPeerConnection() | |||||
| # add to the currect set | |||||
| pcs.add(pc) | |||||
| @pc.on("datachannel") | |||||
| def on_datachannel(channel): | |||||
| @channel.on("message") | |||||
| def on_message(message): | |||||
| if isinstance(message, str) and message.startswith("ping"): | |||||
| channel.send("pong" + message[4:]) | |||||
| @pc.on("iceconnectionstatechange") | |||||
| async def on_iceconnectionstatechange(): | |||||
| log_info("ICE connection state is %s", pc.iceConnectionState) | |||||
| if pc.iceConnectionState == "failed": | |||||
| await pc.close() | |||||
| pcs.discard(pc) | |||||
| doexit = True | |||||
| mixer = MediaPlayer('demo-instruct.wav') | |||||
| @pc.on("track") | |||||
| def on_track(track): | |||||
| log_info("Track %s received", track.kind) | |||||
| if track.kind == "audio": | |||||
| pc.addTrack(mixer.audio) | |||||
| #mixer.addTrack(track) | |||||
| @track.on("ended") | |||||
| async def on_ended(): | |||||
| log_info("Track %s ended", track.kind) | |||||
| # XXX likely not correct | |||||
| await mixer.stop() | |||||
| log_info("Got offer: %s", repr(offer)) | |||||
| # handle offer | |||||
| await pc.setRemoteDescription(offer) | |||||
| # send answer | |||||
| answer = await pc.createAnswer() | |||||
| await pc.setLocalDescription(answer) | |||||
| await ws.send_str(json.dumps({ | |||||
| "sdp": pc.localDescription.sdp, | |||||
| "type": pc.localDescription.type, | |||||
| })) | |||||
| elif msg.type == aiohttp.WSMsgType.ERROR: | |||||
| print('ws connection closed with exception %s' % | |||||
| ws.exception()) | |||||
| print('websocket connection closed') | |||||
| return ws | |||||
| async def on_shutdown(app): | |||||
| shutdown = True | |||||
| # close peer connections | |||||
| coros = [pc.close() for pc in pcs] | |||||
| await asyncio.gather(*coros) | |||||
| pcs.clear() | |||||
| def main(): | |||||
| app = web.Application() | |||||
| app.on_shutdown.append(on_shutdown) | |||||
| app.router.add_get("/", index) | |||||
| app.router.add_get("/jamming.js", jammingjs) | |||||
| app.router.add_get("/ws", ws_handler) | |||||
| web.run_app(app, access_log=None, port=23854, ssl_context=None) | |||||
| if __name__ == '__main__': | |||||
| main() | |||||
| @@ -1,17 +1,193 @@ | |||||
| const jamming = require("./jamming"); | |||||
| import jamming from './jamming'; | |||||
| import { v4 as uuidv4 } from 'uuid'; | |||||
| import WebSocketAsPromised from 'websocket-as-promised'; | |||||
| function sendServer(obj) { | |||||
| let lclobj = Object.assign({}, obj); | |||||
| lclobj.uuid = uuid; | |||||
| return fetch('/offer', { | |||||
| body: JSON.stringify(lclobj), | |||||
| headers: { | |||||
| 'Content-Type': 'application/json' | |||||
| }, | |||||
| method: 'POST' | |||||
| }); | |||||
| } | |||||
| async function runPage() { | async function runPage() { | ||||
| var constraints = { audio: true }; | |||||
| const uuid = uuidv4(); | |||||
| const constatus = document.getElementById('constatus'); | |||||
| const audioSink = document.getElementById('audioSink'); | |||||
| var stream; | |||||
| var wsp; | |||||
| var pc; | |||||
| let stream = null; | |||||
| const constraints = { | |||||
| audio: { | |||||
| latency: .005, /* 5ms latency */ | |||||
| channelCount: 1, | |||||
| noiseSuppression: false, | |||||
| autoGainControl: false, | |||||
| sampleRate: { min: 22050, max: 48000, ideal: 32000 }, | |||||
| } | |||||
| }; | |||||
| /* setup local media */ | |||||
| try { | try { | ||||
| stream = await navigator.mediaDevices.getUserMedia(constraints); | stream = await navigator.mediaDevices.getUserMedia(constraints); | ||||
| console.log('got stream'); | |||||
| } catch(err) { | } catch(err) { | ||||
| console.log('got error'); | |||||
| constatus.textContent = 'Unable to open microphone'; | |||||
| return | |||||
| } | } | ||||
| /* setup server messages */ | |||||
| wsp = new WebSocketAsPromised('ws://' + window.location.host + '/ws', { | |||||
| createWebSocket: url => new WebSocket(url), | |||||
| extractMessageData: event => event, | |||||
| }); | |||||
| wsp.onError.addListener((err) => { | |||||
| constatus.textContent = 'connection to server lost'; | |||||
| }); | |||||
| wsp.onMessage.addListener((message) => { | |||||
| var msg = JSON.parse(message.data); | |||||
| console.log('got message via ws:', msg); | |||||
| if (msg.uuid == uuid) return; | |||||
| if (msg.sdp) { | |||||
| pc.setRemoteDescription(new RTCSessionDescription(msg)); | |||||
| } else if (msg.ice) { | |||||
| pc.addIceCandidate(new RTCIceCandidate(msg.ice)); | |||||
| } | |||||
| }); | |||||
| await wsp.open(); | |||||
| function sendServer(obj) { | |||||
| var lclobj = Object.assign({}, obj); | |||||
| lclobj.uuid = uuid; | |||||
| console.log('send:', lclobj); | |||||
| wsp.send(JSON.stringify(lclobj)); | |||||
| } | |||||
| /* we are initiator */ | |||||
| const configuration = { | |||||
| iceServers: [ { | |||||
| urls: [ | |||||
| 'stun:stun3.l.google.com:19302', | |||||
| /* reduce number of stun servers | |||||
| 'stun:stun.l.google.com:19302', | |||||
| 'stun:stun1.l.google.com:19302', | |||||
| 'stun:stun2.l.google.com:19302', | |||||
| 'stun:stun4.l.google.com:19302', | |||||
| */ | |||||
| 'stun:stun.services.mozilla.com', | |||||
| ] | |||||
| } ] | |||||
| }; | |||||
| pc = new RTCPeerConnection(configuration); | |||||
| pc.onicecandidate = (event) => { | |||||
| if (event.candidate != null) { | |||||
| console.log(event.candidate) | |||||
| sendServer({ ice: event.candidate }); | |||||
| } | |||||
| }; | |||||
| pc.ontrack = (event) => { | |||||
| audioSink.srcObject = event.streams[0]; | |||||
| }; | |||||
| pc.addStream(stream); | |||||
| try { | |||||
| var desc = await pc.createOffer() | |||||
| } catch(err) { | |||||
| constatus.textContent = 'failed to create offer for server: ' + err; | |||||
| return | |||||
| } | |||||
| /* do description filtering here */ | |||||
| await pc.setLocalDescription(desc); | |||||
| var ld = pc.localDescription; | |||||
| sendServer({ sdp: ld.sdp, type: ld.type }); | |||||
| } | } | ||||
| runPage() | |||||
| // #4 of https://stackoverflow.com/questions/37656592/define-global-variable-with-webpack | // #4 of https://stackoverflow.com/questions/37656592/define-global-variable-with-webpack | ||||
| global.runPage = runPage; | global.runPage = runPage; | ||||
| async function foo() { | |||||
| var cert = await RTCPeerConnection.generateCertificate({ | |||||
| name: "ECDSA", namedCurve: "P-256", | |||||
| hash: 'SHA-256' | |||||
| }); | |||||
| // global.pc = new RTCPeerConnection({certificates: [cert]}); | |||||
| } | |||||
| async function bar() { | |||||
| const signaling = new SignalingChannel(); // handles JSON.stringify/parse | |||||
| const configuration = { | |||||
| iceServers: [ { | |||||
| urls: [ | |||||
| 'stun.l.google.com:19302', | |||||
| 'stun1.l.google.com:19302', | |||||
| 'stun2.l.google.com:19302', | |||||
| 'stun3.l.google.com:19302', | |||||
| 'stun4.l.google.com:19302', | |||||
| 'stun:stun.services.mozilla.com', | |||||
| ] | |||||
| } ] | |||||
| }; | |||||
| let pc, channel; | |||||
| // call start() to initiate | |||||
| function start() { | |||||
| pc = new RTCPeerConnection(configuration); | |||||
| // send any ice candidates to the other peer | |||||
| pc.onicecandidate = ({candidate}) => signaling.send({candidate}); | |||||
| // let the "negotiationneeded" event trigger offer generation | |||||
| pc.onnegotiationneeded = async () => { | |||||
| try { | |||||
| await pc.setLocalDescription(); | |||||
| // send the offer to the other peer | |||||
| signaling.send({description: pc.localDescription}); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| } | |||||
| }; | |||||
| // create data channel and setup chat using "negotiated" pattern | |||||
| channel = pc.createDataChannel('chat', {negotiated: true, id: 0}); | |||||
| channel.onopen = () => input.disabled = false; | |||||
| channel.onmessage = ({data}) => showChatMessage(data); | |||||
| input.onkeypress = ({keyCode}) => { | |||||
| // only send when user presses enter | |||||
| if (keyCode != 13) return; | |||||
| channel.send(input.value); | |||||
| } | |||||
| } | |||||
| signaling.onmessage = async ({data: {description, candidate}}) => { | |||||
| if (!pc) start(false); | |||||
| try { | |||||
| if (description) { | |||||
| await pc.setRemoteDescription(description); | |||||
| // if we got an offer, we need to reply with an answer | |||||
| if (description.type == 'offer') { | |||||
| await pc.setLocalDescription(); | |||||
| signaling.send({description: pc.localDescription}); | |||||
| } | |||||
| } else if (candidate) { | |||||
| await pc.addIceCandidate(candidate); | |||||
| } | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| } | |||||
| }; | |||||
| } | |||||