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); | |||||
} | |||||
}; | |||||
} |