Browse Source

Get a minimal WebRTC app talking w/ a Python server... It just plays

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
John-Mark Gurney 4 years ago
parent
commit
2df324b12b
8 changed files with 949 additions and 10 deletions
  1. +1
    -0
      .gitignore
  2. +6
    -3
      dist/audiotest.html
  3. +584
    -1
      dist/jamming.js
  4. +3
    -1
      package.json
  5. +13
    -0
      server/Makefile
  6. +2
    -0
      server/requirements.txt
  7. +159
    -0
      server/server.py
  8. +181
    -5
      src/index.js

+ 1
- 0
.gitignore View File

@@ -5,3 +5,4 @@ node_modules
yarn-error.log
yarn.lock
.DS_Store
server/p

+ 6
- 3
dist/audiotest.html View File

@@ -11,11 +11,14 @@
</head>

<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">

runPage()
//runPage()

/* parameters */
var params = {
@@ -25,7 +28,7 @@ var params = {

window.AudioContext = window.AudioContext || window.webkitAudioContext;

var audioContext = new AudioContext();
//var audioContext = new AudioContext();

recconf = {
samplerRate: 8000,


+ 584
- 1
dist/jamming.js
File diff suppressed because it is too large
View File


+ 3
- 1
package.json View File

@@ -39,8 +39,10 @@
"mocha": "^7.1.1",
"nyc": "^15.0.1",
"sinon": "^9.0.2",
"uuid": "^7.0.3",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
"webpack-cli": "^3.3.11",
"websocket-as-promised": "^1.0.1"
},
"dependencies": {
"inline-worker": "https://github.com/mohayonao/inline-worker.git#7014cd64c3cd6eb884f6743aad682a995e262bb9"


+ 13
- 0
server/Makefile View File

@@ -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)

+ 2
- 0
server/requirements.txt View File

@@ -0,0 +1,2 @@
git+https://github.com/aiortc/aiortc.git#egg=aiortc
aiohttp

+ 159
- 0
server/server.py View File

@@ -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()

+ 181
- 5
src/index.js View File

@@ -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() {
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 {
stream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('got stream');
} 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
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);
}
};
}

Loading…
Cancel
Save