webrtc/samples/js/apprtc/apprtc.py
andrew@webrtc.org bab2aa5113 Add audio and video parameters for setting media constraints.
- These replace the media parameter, now removed.
- Organize the parameter getting a bit.

To describe the new parameters, I'll just copy the code comments here:
Use "audio" and "video" to set the media stream constraints. "true" and
"false" are recognized and interpreted as bools, for example:
  "?audio=true&video=false" (start an audio-only call).
  "?audio=false" (start a video-only call)
If unspecified, the constraint defaults to True.

audio-specific parsing:
To set certain constraints, pass in a comma-separated list of audio
constraint strings. If preceded by a "-", the constraint will be set to
False, and otherwise to True. There is no validation of constraint
strings. Examples:
  "?audio=googEchoCancellation" (enables echo cancellation)
  "?audio=-googEchoCancellation,googAutoGainControl" (disables echo
      cancellation and enables gain control)

TESTED=Verified that passing true, false and various audio constraints
has the desired effect in apprtc.

R=vikasmarwaha@google.com

Review URL: https://webrtc-codereview.appspot.com/2345004

git-svn-id: http://webrtc.googlecode.com/svn/trunk@4919 4adac7df-926f-26a2-2b94-8c16560cd09d
2013-10-03 22:37:29 +00:00

466 lines
16 KiB
Python

#!/usr/bin/python2.4
#
# Copyright 2011 Google Inc. All Rights Reserved.
"""WebRTC Demo
This module demonstrates the WebRTC API by implementing a simple video chat app.
"""
import cgi
import logging
import os
import random
import re
import json
import jinja2
import webapp2
import threading
from google.appengine.api import channel
from google.appengine.ext import db
jinja_environment = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.path.dirname(__file__)))
# Lock for syncing DB operation in concurrent requests handling.
# TODO(brave): keeping working on improving performance with thread syncing.
# One possible method for near future is to reduce the message caching.
LOCK = threading.RLock()
def generate_random(length):
word = ''
for _ in range(length):
word += random.choice('0123456789')
return word
def sanitize(key):
return re.sub('[^a-zA-Z0-9\-]', '-', key)
def make_client_id(room, user):
return room.key().id_or_name() + '/' + user
def get_default_stun_server(user_agent):
default_stun_server = 'stun.l.google.com:19302'
if 'Firefox' in user_agent:
default_stun_server = 'stun.services.mozilla.com'
return default_stun_server
def get_preferred_audio_receive_codec():
return 'opus/48000'
def get_preferred_audio_send_codec(user_agent):
# Empty string means no preference.
preferred_audio_send_codec = ''
# Prefer to send ISAC on Chrome for Android.
if 'Android' in user_agent and 'Chrome' in user_agent:
preferred_audio_send_codec = 'ISAC/16000'
return preferred_audio_send_codec
def make_pc_config(stun_server, turn_server, ts_pwd):
servers = []
if turn_server:
turn_config = 'turn:{}'.format(turn_server)
servers.append({'url':turn_config, 'credential':ts_pwd})
if stun_server:
stun_config = 'stun:{}'.format(stun_server)
servers.append({'url':stun_config})
return {'iceServers':servers}
def create_channel(room, user, duration_minutes):
client_id = make_client_id(room, user)
return channel.create_channel(client_id, duration_minutes)
def make_loopback_answer(message):
message = message.replace("\"offer\"", "\"answer\"")
message = message.replace("a=ice-options:google-ice\\r\\n", "")
return message
def handle_message(room, user, message):
message_obj = json.loads(message)
other_user = room.get_other_user(user)
room_key = room.key().id_or_name()
if message_obj['type'] == 'bye':
# This would remove the other_user in loopback test too.
# So check its availability before forwarding Bye message.
room.remove_user(user)
logging.info('User ' + user + ' quit from room ' + room_key)
logging.info('Room ' + room_key + ' has state ' + str(room))
if other_user and room.has_user(other_user):
if message_obj['type'] == 'offer':
# Special case the loopback scenario.
if other_user == user:
message = make_loopback_answer(message)
on_message(room, other_user, message)
def get_saved_messages(client_id):
return Message.gql("WHERE client_id = :id", id=client_id)
def delete_saved_messages(client_id):
messages = get_saved_messages(client_id)
for message in messages:
message.delete()
logging.info('Deleted the saved message for ' + client_id)
def send_saved_messages(client_id):
messages = get_saved_messages(client_id)
for message in messages:
channel.send_message(client_id, message.msg)
logging.info('Delivered saved message to ' + client_id)
message.delete()
def on_message(room, user, message):
client_id = make_client_id(room, user)
if room.is_connected(user):
channel.send_message(client_id, message)
logging.info('Delivered message to user ' + user)
else:
new_message = Message(client_id = client_id, msg = message)
new_message.put()
logging.info('Saved message for user ' + user)
def make_media_constraints(audio, video, min_resolution, max_resolution):
if not audio or audio.lower() == 'true':
audio_constraints = True
elif audio.lower() == 'false':
audio_constraints = False
else:
audio_constraints = { 'mandatory': {}, 'optional': [] }
if audio:
for constraint in audio.split(','):
# TODO(ajm): We should probably be using the optional list here, but
# Chrome M31 won't override its default settings unless the constraints
# are mandatory. Chrome M32+, however, won't override optional settings.
if constraint.startswith('-'):
audio_constraints['mandatory'][constraint[1:]] = False
else:
audio_constraints['mandatory'][constraint] = True
if not video or video.lower() == 'true':
video_constraints = True
elif video.lower() == 'false':
video_constraints = False
else:
video_constraints = { 'mandatory': {}, 'optional': [] }
if min_resolution:
min_sizes = min_resolution.split('x')
if len(min_sizes) == 2:
video_constraints['mandatory']['minWidth'] = min_sizes[0]
video_constraints['mandatory']['minHeight'] = min_sizes[1]
else:
logging.info('Ignored invalid minre: ' + min_resolution)
if max_resolution:
max_sizes = max_resolution.split('x')
if len(max_sizes) == 2:
video_constraints['mandatory']['maxWidth'] = max_sizes[0]
video_constraints['mandatory']['maxHeight'] = max_sizes[1]
else:
logging.info('Ignored invalid maxre: ' + max_resolution)
return { 'audio': audio_constraints, 'video': video_constraints }
def make_pc_constraints(compat):
constraints = { 'optional': [] }
# For interop with FireFox. Enable DTLS in peerConnection ctor.
if compat.lower() == 'true':
constraints['optional'].append({'DtlsSrtpKeyAgreement': True})
# Disable DTLS in peerConnection ctor for loopback call. The value
# of compat is false for loopback mode.
else:
constraints['optional'].append({'DtlsSrtpKeyAgreement': False})
return constraints
def make_offer_constraints():
constraints = { 'mandatory': {}, 'optional': [] }
return constraints
def append_url_arguments(request, link):
for argument in request.arguments():
if argument != 'r':
link += ('&' + cgi.escape(argument, True) + '=' +
cgi.escape(request.get(argument), True))
return link
# This database is to store the messages from the sender client when the
# receiver client is not ready to receive the messages.
# Use TextProperty instead of StringProperty for msg because
# the session description can be more than 500 characters.
class Message(db.Model):
client_id = db.StringProperty()
msg = db.TextProperty()
class Room(db.Model):
"""All the data we store for a room"""
user1 = db.StringProperty()
user2 = db.StringProperty()
user1_connected = db.BooleanProperty(default=False)
user2_connected = db.BooleanProperty(default=False)
def __str__(self):
result = '['
if self.user1:
result += "%s-%r" % (self.user1, self.user1_connected)
if self.user2:
result += ", %s-%r" % (self.user2, self.user2_connected)
result += ']'
return result
def get_occupancy(self):
occupancy = 0
if self.user1:
occupancy += 1
if self.user2:
occupancy += 1
return occupancy
def get_other_user(self, user):
if user == self.user1:
return self.user2
elif user == self.user2:
return self.user1
else:
return None
def has_user(self, user):
return (user and (user == self.user1 or user == self.user2))
def add_user(self, user):
if not self.user1:
self.user1 = user
elif not self.user2:
self.user2 = user
else:
raise RuntimeError('room is full')
self.put()
def remove_user(self, user):
delete_saved_messages(make_client_id(self, user))
if user == self.user2:
self.user2 = None
self.user2_connected = False
if user == self.user1:
if self.user2:
self.user1 = self.user2
self.user1_connected = self.user2_connected
self.user2 = None
self.user2_connected = False
else:
self.user1 = None
self.user1_connected = False
if self.get_occupancy() > 0:
self.put()
else:
self.delete()
def set_connected(self, user):
if user == self.user1:
self.user1_connected = True
if user == self.user2:
self.user2_connected = True
self.put()
def is_connected(self, user):
if user == self.user1:
return self.user1_connected
if user == self.user2:
return self.user2_connected
class ConnectPage(webapp2.RequestHandler):
def post(self):
key = self.request.get('from')
room_key, user = key.split('/')
with LOCK:
room = Room.get_by_key_name(room_key)
# Check if room has user in case that disconnect message comes before
# connect message with unknown reason, observed with local AppEngine SDK.
if room and room.has_user(user):
room.set_connected(user)
send_saved_messages(make_client_id(room, user))
logging.info('User ' + user + ' connected to room ' + room_key)
logging.info('Room ' + room_key + ' has state ' + str(room))
else:
logging.warning('Unexpected Connect Message to room ' + room_key)
class DisconnectPage(webapp2.RequestHandler):
def post(self):
key = self.request.get('from')
room_key, user = key.split('/')
with LOCK:
room = Room.get_by_key_name(room_key)
if room and room.has_user(user):
other_user = room.get_other_user(user)
room.remove_user(user)
logging.info('User ' + user + ' removed from room ' + room_key)
logging.info('Room ' + room_key + ' has state ' + str(room))
if other_user and other_user != user:
channel.send_message(make_client_id(room, other_user),
'{"type":"bye"}')
logging.info('Sent BYE to ' + other_user)
logging.warning('User ' + user + ' disconnected from room ' + room_key)
class MessagePage(webapp2.RequestHandler):
def post(self):
message = self.request.body
room_key = self.request.get('r')
user = self.request.get('u')
with LOCK:
room = Room.get_by_key_name(room_key)
if room:
handle_message(room, user, message)
else:
logging.warning('Unknown room ' + room_key)
class MainPage(webapp2.RequestHandler):
"""The main UI page, renders the 'index.html' template."""
def get(self):
"""Renders the main page. When this page is shown, we create a new
channel to push asynchronous updates to the client."""
# Get the base url without arguments.
base_url = self.request.path_url
user_agent = self.request.headers['User-Agent']
room_key = sanitize(self.request.get('r'))
stun_server = self.request.get('ss')
if not stun_server:
stun_server = get_default_stun_server(user_agent)
turn_server = self.request.get('ts')
ts_pwd = self.request.get('tp')
# Use "audio" and "video" to set the media stream constraints. "true" and
# "false" are recognized and interpreted as bools, for example:
# "?audio=true&video=false" (start an audio-only call).
# "?audio=false" (start a video-only call)
# If unspecified, the constraint defaults to True.
#
# audio-specific parsing:
# To set certain constraints, pass in a comma-separated list of audio
# constraint strings. If preceded by a "-", the constraint will be set to
# False, and otherwise to True. There is no validation of constraint
# strings. Examples:
# "?audio=googEchoCancellation" (enables echo cancellation)
# "?audio=-googEchoCancellation,googAutoGainControl" (disables echo
# cancellation and enables gain control)
# The strings are defined here:
# https://code.google.com/p/webrtc/source/browse/trunk/talk/app/webrtc/localaudiosource.cc
#
# TODO(ajm): There is currently no video functionality beyond True/False.
# Move the resolution settings here instead?
audio = self.request.get('audio')
video = self.request.get('video')
min_resolution = self.request.get('minre')
max_resolution = self.request.get('maxre')
hd_video = self.request.get('hd')
if hd_video.lower() == 'true':
min_resolution = '1280x720'
audio_send_codec = self.request.get('asc')
if not audio_send_codec:
audio_send_codec = get_preferred_audio_send_codec(user_agent)
audio_receive_codec = self.request.get('arc')
if not audio_receive_codec:
audio_receive_codec = get_preferred_audio_receive_codec()
# Set stereo to false by default.
stereo = 'false'
if self.request.get('stereo'):
stereo = self.request.get('stereo')
# Set compat to true by default.
compat = 'true'
if self.request.get('compat'):
compat = self.request.get('compat')
debug = self.request.get('debug')
if debug == 'loopback':
# Set compat to false as DTLS does not work for loopback.
compat = 'false'
# token_timeout for channel creation, default 30min, max 2 days, min 3min.
token_timeout = self.request.get_range('tt',
min_value = 3,
max_value = 3000,
default = 30)
unittest = self.request.get('unittest')
if unittest:
# Always create a new room for the unit tests.
room_key = generate_random(8)
if not room_key:
room_key = generate_random(8)
redirect = '/?r=' + room_key
redirect = append_url_arguments(self.request, redirect)
self.redirect(redirect)
logging.info('Redirecting visitor to base URL to ' + redirect)
return
user = None
initiator = 0
with LOCK:
room = Room.get_by_key_name(room_key)
if not room and debug != "full":
# New room.
user = generate_random(8)
room = Room(key_name = room_key)
room.add_user(user)
if debug != 'loopback':
initiator = 0
else:
room.add_user(user)
initiator = 1
elif room and room.get_occupancy() == 1 and debug != 'full':
# 1 occupant.
user = generate_random(8)
room.add_user(user)
initiator = 1
else:
# 2 occupants (full).
template = jinja_environment.get_template('full.html')
self.response.out.write(template.render({ 'room_key': room_key }))
logging.info('Room ' + room_key + ' is full')
return
room_link = base_url + '?r=' + room_key
room_link = append_url_arguments(self.request, room_link)
turn_url = 'https://computeengineondemand.appspot.com/'
turn_url = turn_url + 'turn?' + 'username=' + user + '&key=4080218913'
token = create_channel(room, user, token_timeout)
pc_config = make_pc_config(stun_server, turn_server, ts_pwd)
pc_constraints = make_pc_constraints(compat)
offer_constraints = make_offer_constraints()
media_constraints = make_media_constraints(audio, video, min_resolution,
max_resolution)
template_values = {'token': token,
'me': user,
'room_key': room_key,
'room_link': room_link,
'initiator': initiator,
'pc_config': json.dumps(pc_config),
'pc_constraints': json.dumps(pc_constraints),
'offer_constraints': json.dumps(offer_constraints),
'media_constraints': json.dumps(media_constraints),
'turn_url': turn_url,
'stereo': stereo,
'audio_send_codec': audio_send_codec,
'audio_receive_codec': audio_receive_codec
}
if unittest:
target_page = 'test/test_' + unittest + '.html'
else:
target_page = 'index.html'
template = jinja_environment.get_template(target_page)
self.response.out.write(template.render(template_values))
logging.info('User ' + user + ' added to room ' + room_key)
logging.info('Room ' + room_key + ' has state ' + str(room))
app = webapp2.WSGIApplication([
('/', MainPage),
('/message', MessagePage),
('/_ah/channel/connected/', ConnectPage),
('/_ah/channel/disconnected/', DisconnectPage)
], debug=True)