486 lines
17 KiB
Python
486 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import random
|
|
import subprocess
|
|
import tempfile
|
|
import urllib.parse
|
|
import uuid
|
|
from typing import Optional, Union, Any, Dict
|
|
|
|
from hippolyzer.lib.base.datatypes import Vector3
|
|
from hippolyzer.lib.base.events import Event
|
|
from hippolyzer.lib.base.message.message_handler import MessageHandler
|
|
from hippolyzer.lib.base.objects import handle_to_gridxy
|
|
from .connection import VivoxConnection, VivoxMessage
|
|
from ..base.helpers import create_logged_task
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
RESP_LOG = logging.getLogger(__name__ + ".responses")
|
|
|
|
|
|
def launch_slvoice(voice_path, args, env=None):
|
|
return subprocess.Popen([voice_path] + args, env=env)
|
|
|
|
|
|
def uuid_to_vivox(val):
|
|
return (b"x" + base64.b64encode(uuid.UUID(val).bytes, b"-_")).decode("utf8")
|
|
|
|
|
|
def uuid_to_vivox_uri(val):
|
|
return "sip:%s@bhr.vivox.com" % uuid_to_vivox(val)
|
|
|
|
|
|
def vivox_to_uuid(val):
|
|
# Pull the base64-encoded UUID out of the URI
|
|
val = val.split(":")[-1].split("@")[0][1:]
|
|
return str(uuid.UUID(bytes=base64.b64decode(val, b"-_")))
|
|
|
|
|
|
class VoiceClient:
|
|
SERVER_URL = "https://www.bhr.vivox.com/api2/"
|
|
|
|
def __init__(self, host: str, port: int):
|
|
self._host = host
|
|
self._port = port
|
|
|
|
self.logged_in = asyncio.Event()
|
|
self.ready = asyncio.Event()
|
|
self.session_ready = asyncio.Event()
|
|
self.session_added = Event()
|
|
self.channel_info_updated = Event()
|
|
self.participant_added = Event()
|
|
self.participant_updated = Event()
|
|
self.participant_removed = Event()
|
|
self.capture_devices_received = Event()
|
|
self.render_devices_received = Event()
|
|
self.render_devices = {}
|
|
self.capture_devices = {}
|
|
|
|
self._pending_req_futures: dict[str, asyncio.Future] = {}
|
|
|
|
self._connector_handle: Optional[str] = None
|
|
self._session_handle: Optional[str] = None
|
|
self._session_group_handle: Optional[str] = None
|
|
self._account_handle: Optional[str] = None
|
|
self._account_uri: Optional[str] = None
|
|
self._username: Optional[str] = None
|
|
self._password: Optional[str] = None
|
|
self._display_name: Optional[str] = None
|
|
self._uri: Optional[str] = None
|
|
self._participants: Dict[str, dict] = {}
|
|
|
|
self._mic_muted = False
|
|
self._region_global_x = 0
|
|
self._region_global_y = 0
|
|
|
|
self._pos = Vector3(0, 0, 0)
|
|
|
|
self.vivox_conn: Optional[VivoxConnection] = None
|
|
self._poll_task = create_logged_task(self._poll_messages(), "Poll Vivox messages")
|
|
self.event_handler: MessageHandler[VivoxMessage, str] = MessageHandler(take_by_default=False)
|
|
|
|
self.event_handler.subscribe(
|
|
"VoiceServiceConnectionStateChangedEvent",
|
|
self._handle_voice_service_connection_state_changed
|
|
)
|
|
self.event_handler.subscribe("AccountLoginStateChangeEvent", self._handle_account_login_state_change)
|
|
self.event_handler.subscribe("SessionAddedEvent", self._handle_session_added)
|
|
self.event_handler.subscribe("SessionRemovedEvent", self._handle_session_removed)
|
|
self.event_handler.subscribe("ParticipantAddedEvent", self._handle_participant_added)
|
|
self.event_handler.subscribe("ParticipantUpdatedEvent", self._handle_participant_updated)
|
|
self.event_handler.subscribe("ParticipantRemovedEvent", self._handle_participant_removed)
|
|
|
|
@property
|
|
def username(self):
|
|
return self._username
|
|
|
|
@property
|
|
def password(self):
|
|
return self._password
|
|
|
|
@property
|
|
def display_name(self):
|
|
return self._display_name
|
|
|
|
@property
|
|
def global_pos(self):
|
|
return self._pos
|
|
|
|
@property
|
|
def region_pos(self):
|
|
return self._global_to_region(self.global_pos)
|
|
|
|
@property
|
|
def uri(self):
|
|
return self._uri
|
|
|
|
@property
|
|
def participants(self):
|
|
# TODO: wrap in something to make immutable
|
|
return self._participants
|
|
|
|
def close(self):
|
|
if self.vivox_conn is not None:
|
|
self.vivox_conn.close()
|
|
self._poll_task.cancel()
|
|
self._poll_task = None
|
|
|
|
async def aclose(self):
|
|
if self._account_handle:
|
|
await self.logout()
|
|
self.close()
|
|
|
|
@classmethod
|
|
async def simple_init(
|
|
cls,
|
|
voice_path: str,
|
|
host: Optional[str] = None,
|
|
port: Optional[int] = None,
|
|
env: Optional[dict] = None
|
|
):
|
|
"""Simple initializer for standing up a client"""
|
|
if not host:
|
|
host = "127.0.0.1"
|
|
if not port:
|
|
port = random.randrange(40000, 60000)
|
|
|
|
str_addr = "%s:%s" % (host, port)
|
|
launch_slvoice(voice_path, ["-i", str_addr, "-m", "component"], env=env)
|
|
# HACK: wait for the process to start listening
|
|
await asyncio.sleep(0.2)
|
|
|
|
client = cls(host, port)
|
|
await client.create_vivox_connection()
|
|
await client.ready.wait()
|
|
return client
|
|
|
|
async def create_vivox_connection(self):
|
|
reader, writer = await asyncio.open_connection(host=self._host, port=self._port)
|
|
self.vivox_conn = VivoxConnection(reader, writer)
|
|
|
|
async def create_connector(self):
|
|
# TODO: Move all this extra crap out of here
|
|
devices = (await self.send_message("Aux.GetCaptureDevices.1", {}))["Results"]
|
|
self.capture_devices_received.notify(devices)
|
|
self.capture_devices.clear()
|
|
self.capture_devices.update(devices)
|
|
|
|
devices = (await self.send_message("Aux.GetRenderDevices.1", {}))["Results"]
|
|
self.render_devices_received.notify(devices)
|
|
self.render_devices.clear()
|
|
self.render_devices.update(devices)
|
|
|
|
await self.set_speakers_muted(False)
|
|
await self.set_speaker_volume(62)
|
|
await self.set_mic_muted(True)
|
|
await self.set_mic_volume(50)
|
|
|
|
connector_resp = await self.send_message("Connector.Create.1", {
|
|
"ClientName": "V2 SDK",
|
|
"AccountManagementServer": self.SERVER_URL,
|
|
"Mode": "Normal",
|
|
"MinimumPort": 30000,
|
|
"MaximumPort": 50000,
|
|
"Logging": {
|
|
"Folder": tempfile.gettempdir(),
|
|
"FileNamePrefix": "VivConnector",
|
|
"FileNameSuffix": ".log",
|
|
"LogLevel": 1
|
|
},
|
|
"Application": "",
|
|
"MaxCalls": 12,
|
|
})
|
|
|
|
self._connector_handle = connector_resp['Results']['ConnectorHandle']
|
|
self.ready.set()
|
|
|
|
async def login(self, username: Union[uuid.UUID, str], password: str):
|
|
# UUID, convert to Vivox format
|
|
if isinstance(username, uuid.UUID) or len(username) == 36:
|
|
username = uuid_to_vivox(username)
|
|
|
|
self._username = username
|
|
self._password = password
|
|
if not self._connector_handle:
|
|
raise Exception("Need a connector handle to log in")
|
|
if self._account_handle:
|
|
await self.logout()
|
|
|
|
resp = await self.send_message("Account.Login.1", {
|
|
"ConnectorHandle": self._connector_handle,
|
|
"AccountName": username,
|
|
"AccountPassword": password,
|
|
"AudioSessionAnswerMode": "VerifyAnswer",
|
|
"EnableBuddiesAndPresence": "false",
|
|
"BuddyManagementMode": "Application",
|
|
"ParticipantPropertyFrequency": 5,
|
|
})
|
|
|
|
if resp["ReturnCode"] != 0:
|
|
raise Exception(resp)
|
|
|
|
self._display_name = urllib.parse.unquote(resp["Results"]["DisplayName"])
|
|
self._account_uri = resp["Results"]["Uri"]
|
|
await self.logged_in.wait()
|
|
|
|
return resp
|
|
|
|
async def logout(self):
|
|
if self._session_handle:
|
|
await self.leave_session()
|
|
|
|
if self._account_handle:
|
|
await self.send_message("Account.Logout.1", {
|
|
"AccountHandle": self._account_handle,
|
|
})
|
|
self._account_handle = None
|
|
self._account_uri = None
|
|
self.logged_in.clear()
|
|
|
|
async def join_session(self, uri: str, region_handle: Optional[int] = None):
|
|
if self._session_handle:
|
|
await self.leave_session()
|
|
|
|
self.set_ref_region(region_handle)
|
|
|
|
self._uri = uri
|
|
|
|
await self.send_message("Session.Create.1", {
|
|
"AccountHandle": self._account_handle,
|
|
"URI": uri,
|
|
"ConnectAudio": "true",
|
|
"ConnectText": "false",
|
|
"VoiceFontID": 0,
|
|
"Name": ""
|
|
})
|
|
# wait until we're actually added
|
|
await self.session_ready.wait()
|
|
|
|
async def leave_session(self):
|
|
await self.send_message("SessionGroup.Terminate.1", {
|
|
"SessionGroupHandle": self._session_group_handle,
|
|
})
|
|
self.session_ready.clear()
|
|
|
|
# TODO: refactor into a collection
|
|
for participant in self._participants.values():
|
|
self.participant_removed.notify(participant)
|
|
self._participants.clear()
|
|
self._session_handle = None
|
|
self._session_group_handle = None
|
|
self._region_global_x = 0
|
|
self._region_global_y = 0
|
|
self._uri = None
|
|
|
|
def set_3d_pos(self, pos: Vector3, vel: Vector3 = Vector3(0, 0, 0)) -> asyncio.Future:
|
|
"""Set global 3D position, in Vivox coordinates"""
|
|
self._pos = pos
|
|
future = self.send_message("Session.Set3DPosition.1", {
|
|
"SessionHandle": self._session_handle,
|
|
"SpeakerPosition": self._build_position_dict(pos),
|
|
"ListenerPosition": self._build_position_dict(pos, vel=vel),
|
|
})
|
|
self._channel_info_updated()
|
|
return future
|
|
|
|
def set_region_3d_pos(self, pos: Vector3, vel: Vector3 = Vector3(0, 0, 0)) -> asyncio.Future:
|
|
"""Set 3D position, in region-local coordinates"""
|
|
vel = Vector3(vel[0], vel[2], -vel[1])
|
|
return self.set_3d_pos(self._region_to_global(pos), vel=vel)
|
|
|
|
def set_speakers_muted(self, val: bool):
|
|
return self.send_message("Connector.MuteLocalSpeaker.1", {
|
|
"Value": json.dumps(val),
|
|
"ConnectorHandle": self._connector_handle
|
|
})
|
|
|
|
def set_mic_muted(self, val: bool):
|
|
self._mic_muted = val
|
|
|
|
return self.send_message("Connector.MuteLocalMic.1", {
|
|
"Value": json.dumps(val),
|
|
"ConnectorHandle": self._connector_handle
|
|
})
|
|
|
|
def set_mic_volume(self, vol: int):
|
|
return self.send_message("Connector.SetLocalMicVolume.1", {
|
|
"Value": vol,
|
|
"ConnectorHandle": self._connector_handle
|
|
})
|
|
|
|
def set_speaker_volume(self, vol: int):
|
|
return self.send_message("Connector.SetLocalSpeakerVolume.1", {
|
|
"Value": vol,
|
|
"ConnectorHandle": self._connector_handle
|
|
})
|
|
|
|
def set_capture_device(self, device: str):
|
|
return self.send_message("Aux.SetCaptureDevice.1", {
|
|
"CaptureDeviceSpecifier": device,
|
|
})
|
|
|
|
def set_participant_volume(self, participant: str, vol: int):
|
|
return self.send_message("Session.SetParticipantVolumeForMe.1", {
|
|
"SessionHandle": self._session_handle,
|
|
"ParticipantURI": participant,
|
|
"Volume": vol,
|
|
})
|
|
|
|
async def get_channel_info(self, uri: str) -> dict:
|
|
return await self.send_message("Account.ChannelGetInfo.1", {
|
|
"AccountHandle": self._account_handle,
|
|
"URI": uri
|
|
})
|
|
|
|
def send_web_call(self, rel_path: str, params: dict) -> asyncio.Future[dict]:
|
|
"""Make a call to a Vivox Web API"""
|
|
return self.send_message("Account.WebCall.1", {
|
|
"AccountHandle": self._account_handle,
|
|
"RelativePath": rel_path,
|
|
"Parameters": params,
|
|
})
|
|
|
|
def send_message(self, msg_type: str, data: Any) -> asyncio.Future[dict]:
|
|
request_id = self._make_request_id()
|
|
# This is apparently what the viewer does, not clear if
|
|
# request_id has any semantic significance
|
|
if msg_type == "Session.Create.1":
|
|
request_id = data["URI"]
|
|
|
|
RESP_LOG.debug("%s %s %s %r" % ("Request", request_id, msg_type, data))
|
|
|
|
create_logged_task(self.vivox_conn.send_request(request_id, msg_type, data), "Send Vivox message")
|
|
future = asyncio.Future()
|
|
self._pending_req_futures[request_id] = future
|
|
return future
|
|
|
|
def send_raw(self, data: bytes):
|
|
return self.vivox_conn.send_raw(data)
|
|
|
|
def set_ref_region(self, region_handle: Optional[int]):
|
|
"""Set reference position for region-local coordinates"""
|
|
if region_handle is not None:
|
|
self._region_global_x, self._region_global_y = handle_to_gridxy(region_handle)
|
|
else:
|
|
self._region_global_x, self._region_global_y = (0, 0)
|
|
self._channel_info_updated()
|
|
|
|
async def _poll_messages(self):
|
|
while not self.vivox_conn:
|
|
await asyncio.sleep(0.001)
|
|
|
|
async for msg in self.vivox_conn.read_messages():
|
|
try:
|
|
RESP_LOG.debug(repr(msg))
|
|
if msg.type == "Event":
|
|
self.event_handler.handle(msg)
|
|
elif msg.type == "Response":
|
|
# Might not have this request ID if it was sent directly via the socket
|
|
if msg.request_id in self._pending_req_futures:
|
|
self._pending_req_futures[msg.request_id].set_result(msg.data)
|
|
del self._pending_req_futures[msg.request_id]
|
|
except Exception:
|
|
LOG.exception("Error in response handler?")
|
|
|
|
async def _handle_voice_service_connection_state_changed(self, _msg: VivoxMessage):
|
|
await self.create_connector()
|
|
|
|
def _handle_account_login_state_change(self, msg: VivoxMessage):
|
|
if msg.data.get('StatusString') == "OK" and msg.data['State'] == '1':
|
|
self._account_handle = msg.data['AccountHandle']
|
|
self.logged_in.set()
|
|
else:
|
|
self.logged_in.clear()
|
|
self._account_uri = None
|
|
self._account_handle = None
|
|
|
|
def _handle_session_added(self, msg: VivoxMessage):
|
|
self._session_handle = msg.data["SessionHandle"]
|
|
self._session_group_handle = msg.data["SessionGroupHandle"]
|
|
self.session_added.notify(self._session_handle)
|
|
# We still have to wait for ourselves to be added as a participant, wait on
|
|
# that to set the session_ready event.
|
|
|
|
def _handle_session_removed(self, _msg: VivoxMessage):
|
|
self._session_handle = None
|
|
# We often don't get all the `ParticipantRemoved`s before the session dies,
|
|
# clear out the participant list.
|
|
for participant in tuple(self._participants.keys()):
|
|
self._remove_participant(participant)
|
|
self.session_ready.clear()
|
|
|
|
def _handle_participant_added(self, msg: VivoxMessage):
|
|
self._participants[msg.data["ParticipantUri"]] = msg.data
|
|
self.participant_added.notify(msg.data)
|
|
if msg.data["ParticipantUri"] == self._account_uri and not self.session_ready.is_set():
|
|
self.session_ready.set()
|
|
|
|
def _handle_participant_updated(self, msg: VivoxMessage):
|
|
participant_uri = msg.data["ParticipantUri"]
|
|
if participant_uri in self._participants:
|
|
participant = self._participants[participant_uri]
|
|
participant.update(msg.data)
|
|
self.participant_updated.notify(participant)
|
|
|
|
def _handle_participant_removed(self, msg: VivoxMessage):
|
|
self._remove_participant(msg.data["ParticipantUri"])
|
|
|
|
def _remove_participant(self, participant_uri: str):
|
|
if participant_uri in self._participants:
|
|
participant = self._participants[participant_uri]
|
|
del self._participants[participant_uri]
|
|
self.participant_removed.notify(participant)
|
|
|
|
def _global_to_region(self, pos: Vector3):
|
|
x = pos.X - self._region_global_x * 256
|
|
z = pos.Z + self._region_global_y * 256
|
|
# Vivox uses a different coordinate system than SL, Y is up!
|
|
return Vector3(x, -z, pos.Y)
|
|
|
|
def _region_to_global(self, pos: Vector3):
|
|
x = pos.X + self._region_global_x * 256
|
|
y = pos.Y + self._region_global_y * 256
|
|
return Vector3(x, pos.Z, -y)
|
|
|
|
def _build_position_dict(self, pos: Vector3, vel: Vector3 = Vector3(0, 0, 0)) -> dict:
|
|
return {
|
|
"Position": {
|
|
"X": pos.X,
|
|
"Y": pos.Y,
|
|
"Z": pos.Z,
|
|
},
|
|
"Velocity": {
|
|
"X": vel.X,
|
|
"Y": vel.Y,
|
|
"Z": vel.Z,
|
|
},
|
|
"AtOrientation": {
|
|
"X": "1.29938e-05",
|
|
"Y": 0,
|
|
"Z": -1,
|
|
},
|
|
"UpOrientation": {
|
|
"X": 0,
|
|
"Y": 1,
|
|
"Z": 0,
|
|
},
|
|
"LeftOrientation": {
|
|
"X": -1,
|
|
"Y": 0,
|
|
"Z": "-1.29938e-05",
|
|
}
|
|
}
|
|
|
|
def _channel_info_updated(self):
|
|
pos = self.global_pos
|
|
if self._region_global_x is not None:
|
|
pos = self.region_pos
|
|
self.channel_info_updated.notify(pos)
|
|
|
|
def _make_request_id(self):
|
|
return str(uuid.uuid4())
|