Files
Hippolyzer/hippolyzer/lib/voice/client.py

486 lines
17 KiB
Python
Raw Normal View History

2023-12-18 05:27:32 +00:00
from __future__ import annotations
2023-12-18 02:01:10 +00:00
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
2023-12-18 02:01:10 +00:00
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
2023-12-18 06:10:51 +00:00
from .connection import VivoxConnection, VivoxMessage
from ..base.helpers import create_logged_task
2023-12-18 02:01:10 +00:00
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"-_")))
2023-12-18 03:29:40 +00:00
class VoiceClient:
SERVER_URL = "https://www.bhr.vivox.com/api2/"
2023-12-18 02:01:10 +00:00
def __init__(self, host: str, port: int):
2023-12-18 02:01:10 +00:00
self._host = host
self._port = port
self.logged_in = asyncio.Event()
self.ready = asyncio.Event()
2023-12-18 23:32:57 +00:00
self.session_ready = asyncio.Event()
2023-12-18 02:01:10 +00:00
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()
2023-12-18 23:32:57 +00:00
self.render_devices_received = Event()
self.render_devices = {}
self.capture_devices = {}
2023-12-18 02:01:10 +00:00
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
2023-12-18 02:01:10 +00:00
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] = {}
2023-12-18 02:01:10 +00:00
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")
2023-12-19 01:31:49 +00:00
self.event_handler: MessageHandler[VivoxMessage, str] = MessageHandler(take_by_default=False)
2023-12-18 02:01:10 +00:00
2023-12-19 01:31:49 +00:00
self.event_handler.subscribe(
"VoiceServiceConnectionStateChangedEvent",
self._handle_voice_service_connection_state_changed
)
2023-12-19 01:31:49 +00:00
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)
2023-12-18 02:01:10 +00:00
@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()
2023-12-18 02:01:10 +00:00
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)
2023-12-18 02:01:10 +00:00
2023-12-18 23:32:57 +00:00
devices = (await self.send_message("Aux.GetRenderDevices.1", {}))["Results"]
self.render_devices_received.notify(devices)
self.render_devices.clear()
self.render_devices.update(devices)
2023-12-18 02:01:10 +00:00
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",
2023-12-19 18:43:08 +00:00
"LogLevel": 1
2023-12-18 02:01:10 +00:00
},
"Application": "",
"MaxCalls": 12,
})
self._connector_handle = connector_resp['Results']['ConnectorHandle']
self.ready.set()
2023-12-18 02:01:10 +00:00
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()
2023-12-18 02:01:10 +00:00
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()
2023-12-18 02:01:10 +00:00
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
2023-12-18 23:32:57 +00:00
await self.session_ready.wait()
2023-12-18 02:01:10 +00:00
async def leave_session(self):
await self.send_message("SessionGroup.Terminate.1", {
"SessionGroupHandle": self._session_group_handle,
})
2023-12-18 23:32:57 +00:00
self.session_ready.clear()
2023-12-18 02:01:10 +00:00
# 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
2023-12-18 21:34:39 +00:00
def set_3d_pos(self, pos: Vector3, vel: Vector3 = Vector3(0, 0, 0)) -> asyncio.Future:
2023-12-18 02:01:10 +00:00
"""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
2023-12-18 21:34:39 +00:00
def set_region_3d_pos(self, pos: Vector3, vel: Vector3 = Vector3(0, 0, 0)) -> asyncio.Future:
2023-12-18 02:01:10 +00:00
"""Set 3D position, in region-local coordinates"""
vel = Vector3(vel[0], vel[2], -vel[1])
2023-12-18 21:34:39 +00:00
return self.set_3d_pos(self._region_to_global(pos), vel=vel)
2023-12-18 02:01:10 +00:00
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]:
2023-12-18 05:27:32 +00:00
request_id = self._make_request_id()
2023-12-18 02:01:10 +00:00
# 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")
2023-12-18 02:01:10 +00:00
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)
2023-12-18 05:27:32 +00:00
async for msg in self.vivox_conn.read_messages():
2023-12-18 02:01:10 +00:00
try:
2023-12-18 05:27:32 +00:00
RESP_LOG.debug(repr(msg))
if msg.type == "Event":
2023-12-19 01:31:49 +00:00
self.event_handler.handle(msg)
2023-12-18 05:27:32 +00:00
elif msg.type == "Response":
2023-12-18 02:01:10 +00:00
# Might not have this request ID if it was sent directly via the socket
2023-12-18 05:27:32 +00:00
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]
2023-12-18 02:01:10 +00:00
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)
2023-12-18 23:32:57 +00:00
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):
2023-12-18 02:01:10 +00:00
if participant_uri in self._participants:
participant = self._participants[participant_uri]
2023-12-18 02:01:10 +00:00
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)
2023-12-18 05:27:32 +00:00
def _make_request_id(self):
return str(uuid.uuid4())