/* * Copyright (c) 2006-2014, openmetaverse.org * All rights reserved. * * - Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * - Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * - Neither the name of the openmetaverse.org nor the names * of its contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ //#define DEBUG_VOICE using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.IO; using System.Threading; using OpenMetaverse; using OpenMetaverse.StructuredData; namespace OpenMetaverse.Voice { public partial class VoiceGateway : IDisposable { // These states should be in increasing order of 'completeness' // so that the (int) values can drive a progress bar. public enum ConnectionState { None = 0, Provisioned, DaemonStarted, DaemonConnected, ConnectorConnected, AccountLogin, RegionCapAvailable, SessionRunning } internal string sipServer = ""; private string acctServer = "https://www.bhr.vivox.com/api2/"; private string connectionHandle; private string accountHandle; private string sessionHandle; // Parameters to Vivox daemon private string slvoicePath = ""; private string slvoiceArgs = "-ll 5"; private string daemonNode = "127.0.0.1"; private int daemonPort = 37331; private string voiceUser; private string voicePassword; private string spatialUri; private string spatialCredentials; // Session management private Dictionary sessions; private VoiceSession spatialSession; private Uri currentParcelCap; private Uri nextParcelCap; private string regionName; // Position update thread private Thread posThread; private ManualResetEvent posRestart; public GridClient Client; private VoicePosition position; private Vector3d oldPosition; private Vector3d oldAt; // Audio interfaces private List inputDevices; /// /// List of audio input devices /// public List CaptureDevices { get { return inputDevices; } } private List outputDevices; /// /// List of audio output devices /// public List PlaybackDevices { get { return outputDevices; } } private string currentCaptureDevice; private string currentPlaybackDevice; private bool testing = false; public event EventHandler OnSessionCreate; public event EventHandler OnSessionRemove; public delegate void VoiceConnectionChangeCallback(ConnectionState state); public event VoiceConnectionChangeCallback OnVoiceConnectionChange; public delegate void VoiceMicTestCallback(float level); public event VoiceMicTestCallback OnVoiceMicTest; public VoiceGateway(GridClient c) { Random rand = new Random(); daemonPort = rand.Next(34000, 44000); Client = c; sessions = new Dictionary(); position = new VoicePosition(); position.UpOrientation = new Vector3d(0.0, 1.0, 0.0); position.Velocity = new Vector3d(0.0, 0.0, 0.0); oldPosition = new Vector3d(0, 0, 0); oldAt = new Vector3d(1, 0, 0); slvoiceArgs = " -ll -1"; // Min logging slvoiceArgs += " -i 127.0.0.1:" + daemonPort.ToString(); // slvoiceArgs += " -lf " + control.instance.ClientDir; } /// /// Start up the Voice service. /// public void Start() { // Start the background thread if (posThread != null && posThread.IsAlive) posThread.Abort(); posThread = new Thread(new ThreadStart(PositionThreadBody)); posThread.Name = "VoicePositionUpdate"; posThread.IsBackground = true; posRestart = new ManualResetEvent(false); posThread.Start(); Client.Network.EventQueueRunning += new EventHandler(Network_EventQueueRunning); // Connection events OnDaemonRunning += new VoiceGateway.DaemonRunningCallback(connector_OnDaemonRunning); OnDaemonCouldntRun += new VoiceGateway.DaemonCouldntRunCallback(connector_OnDaemonCouldntRun); OnConnectorCreateResponse += new EventHandler(connector_OnConnectorCreateResponse); OnDaemonConnected += new DaemonConnectedCallback(connector_OnDaemonConnected); OnDaemonCouldntConnect += new DaemonCouldntConnectCallback(connector_OnDaemonCouldntConnect); OnAuxAudioPropertiesEvent += new EventHandler(connector_OnAuxAudioPropertiesEvent); // Session events OnSessionStateChangeEvent += new EventHandler(connector_OnSessionStateChangeEvent); OnSessionAddedEvent += new EventHandler(connector_OnSessionAddedEvent); // Session Participants events OnSessionParticipantUpdatedEvent += new EventHandler(connector_OnSessionParticipantUpdatedEvent); OnSessionParticipantAddedEvent += new EventHandler(connector_OnSessionParticipantAddedEvent); // Device events OnAuxGetCaptureDevicesResponse += new EventHandler(connector_OnAuxGetCaptureDevicesResponse); OnAuxGetRenderDevicesResponse += new EventHandler(connector_OnAuxGetRenderDevicesResponse); // Generic status response OnVoiceResponse += new EventHandler(connector_OnVoiceResponse); // Account events OnAccountLoginResponse += new EventHandler(connector_OnAccountLoginResponse); Logger.Log("Voice initialized", Helpers.LogLevel.Info); // If voice provisioning capability is already available, // proceed with voice startup. Otherwise the EventQueueRunning // event will do it. System.Uri vCap = Client.Network.CurrentSim.Caps.CapabilityURI("ProvisionVoiceAccountRequest"); if (vCap != null) RequestVoiceProvision(vCap); } /// /// Handle miscellaneous request status /// /// /// /// ///If something goes wrong, we log it. void connector_OnVoiceResponse(object sender, VoiceGateway.VoiceResponseEventArgs e) { if (e.StatusCode == 0) return; Logger.Log(e.Message + " on " + sender as string, Helpers.LogLevel.Error); } public void Stop() { Client.Network.EventQueueRunning -= new EventHandler(Network_EventQueueRunning); // Connection events OnDaemonRunning -= new VoiceGateway.DaemonRunningCallback(connector_OnDaemonRunning); OnDaemonCouldntRun -= new VoiceGateway.DaemonCouldntRunCallback(connector_OnDaemonCouldntRun); OnConnectorCreateResponse -= new EventHandler(connector_OnConnectorCreateResponse); OnDaemonConnected -= new VoiceGateway.DaemonConnectedCallback(connector_OnDaemonConnected); OnDaemonCouldntConnect -= new VoiceGateway.DaemonCouldntConnectCallback(connector_OnDaemonCouldntConnect); OnAuxAudioPropertiesEvent -= new EventHandler(connector_OnAuxAudioPropertiesEvent); // Session events OnSessionStateChangeEvent -= new EventHandler(connector_OnSessionStateChangeEvent); OnSessionAddedEvent -= new EventHandler(connector_OnSessionAddedEvent); // Session Participants events OnSessionParticipantUpdatedEvent -= new EventHandler(connector_OnSessionParticipantUpdatedEvent); OnSessionParticipantAddedEvent -= new EventHandler(connector_OnSessionParticipantAddedEvent); OnSessionParticipantRemovedEvent -= new EventHandler(connector_OnSessionParticipantRemovedEvent); // Tuning events OnAuxGetCaptureDevicesResponse -= new EventHandler(connector_OnAuxGetCaptureDevicesResponse); OnAuxGetRenderDevicesResponse -= new EventHandler(connector_OnAuxGetRenderDevicesResponse); // Account events OnAccountLoginResponse -= new EventHandler(connector_OnAccountLoginResponse); // Stop the background thread if (posThread != null) { PosUpdating(false); if (posThread.IsAlive) posThread.Abort(); posThread = null; } // Close all sessions foreach (VoiceSession s in sessions.Values) { if (OnSessionRemove != null) OnSessionRemove(s, EventArgs.Empty); s.Close(); } // Clear out lots of state so in case of restart we begin at the beginning. currentParcelCap = null; sessions.Clear(); accountHandle = null; voiceUser = null; voicePassword = null; SessionTerminate(sessionHandle); sessionHandle = null; AccountLogout(accountHandle); accountHandle = null; ConnectorInitiateShutdown(connectionHandle); connectionHandle = null; StopDaemon(); } /// /// Cleanup oject resources /// public void Dispose() { Stop(); } internal string GetVoiceDaemonPath() { string myDir = Path.GetDirectoryName( (System.Reflection.Assembly.GetEntryAssembly() ?? typeof (VoiceGateway).Assembly).Location); if (Environment.OSVersion.Platform != PlatformID.MacOSX && Environment.OSVersion.Platform != PlatformID.Unix) { string localDaemon = Path.Combine(myDir, Path.Combine("voice", "SLVoice.exe")); if (File.Exists(localDaemon)) return localDaemon; string progFiles; if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ProgramFiles(x86)"))) { progFiles = Environment.GetEnvironmentVariable("ProgramFiles(x86)"); } else { progFiles = Environment.GetEnvironmentVariable("ProgramFiles"); } if (System.IO.File.Exists(Path.Combine(progFiles, @"SecondLife" + Path.DirectorySeparatorChar + @"SLVoice.exe"))) { return Path.Combine(progFiles, @"SecondLife" + Path.DirectorySeparatorChar + @"SLVoice.exe"); } return Path.Combine(myDir, @"SLVoice.exe"); } else { string localDaemon = Path.Combine(myDir, Path.Combine("voice", "SLVoice")); if (File.Exists(localDaemon)) return localDaemon; return Path.Combine(myDir,"SLVoice"); } } void RequestVoiceProvision(System.Uri cap) { OpenMetaverse.Http.CapsClient capClient = new OpenMetaverse.Http.CapsClient(cap); capClient.OnComplete += new OpenMetaverse.Http.CapsClient.CompleteCallback(cClient_OnComplete); OSD postData = new OSD(); // STEP 0 Logger.Log("Requesting voice capability", Helpers.LogLevel.Info); capClient.BeginGetResponse(postData, OSDFormat.Xml, 10000); } /// /// Request voice cap when changing regions /// void Network_EventQueueRunning(object sender, EventQueueRunningEventArgs e) { // We only care about the sim we are in. if (e.Simulator != Client.Network.CurrentSim) return; // Did we provision voice login info? if (string.IsNullOrEmpty(voiceUser)) { // The startup steps are // 0. Get voice account info // 1. Start Daemon // 2. Create TCP connection // 3. Create Connector // 4. Account login // 5. Create session // Get the voice provisioning data System.Uri vCap = Client.Network.CurrentSim.Caps.CapabilityURI("ProvisionVoiceAccountRequest"); // Do we have voice capability? if (vCap == null) { Logger.Log("Null voice capability after event queue running", Helpers.LogLevel.Warning); } else { RequestVoiceProvision(vCap); } return; } else { // Change voice session for this region. ParcelChanged(); } } #region Participants void connector_OnSessionParticipantUpdatedEvent(object sender, ParticipantUpdatedEventArgs e) { VoiceSession s = FindSession(e.SessionHandle, false); if (s == null) return; s.ParticipantUpdate(e.URI, e.IsMuted, e.IsSpeaking, e.Volume, e.Energy); } public string SIPFromUUID(UUID id) { return "sip:" + nameFromID(id) + "@" + sipServer; } private static string nameFromID(UUID id) { string result = null; if (id == UUID.Zero) return result; // Prepending this apparently prevents conflicts with reserved names inside the vivox and diamondware code. result = "x"; // Base64 encode and replace the pieces of base64 that are less compatible // with e-mail local-parts. // See RFC-4648 "Base 64 Encoding with URL and Filename Safe Alphabet" byte[] encbuff = id.GetBytes(); result += Convert.ToBase64String(encbuff); result = result.Replace('+', '-'); result = result.Replace('/', '_'); return result; } void connector_OnSessionParticipantAddedEvent(object sender, ParticipantAddedEventArgs e) { VoiceSession s = FindSession(e.SessionHandle, false); if (s == null) { Logger.Log("Orphan participant", Helpers.LogLevel.Error); return; } s.AddParticipant(e.URI); } void connector_OnSessionParticipantRemovedEvent(object sender, ParticipantRemovedEventArgs e) { VoiceSession s = FindSession(e.SessionHandle, false); if (s == null) return; s.RemoveParticipant(e.URI); } #endregion #region Sessions void connector_OnSessionAddedEvent(object sender, SessionAddedEventArgs e) { sessionHandle = e.SessionHandle; // Create our session context. VoiceSession s = FindSession(sessionHandle, true); s.RegionName = regionName; spatialSession = s; // Tell any user-facing code. if (OnSessionCreate != null) OnSessionCreate(s, null); Logger.Log("Added voice session in " + regionName, Helpers.LogLevel.Info); } /// /// Handle a change in session state /// void connector_OnSessionStateChangeEvent(object sender, SessionStateChangeEventArgs e) { VoiceSession s; switch (e.State) { case VoiceGateway.SessionState.Connected: s = FindSession(e.SessionHandle, true); sessionHandle = e.SessionHandle; s.RegionName = regionName; spatialSession = s; Logger.Log("Voice connected in " + regionName, Helpers.LogLevel.Info); // Tell any user-facing code. if (OnSessionCreate != null) OnSessionCreate(s, null); break; case VoiceGateway.SessionState.Disconnected: s = FindSession(sessionHandle, false); sessions.Remove(sessionHandle); if (s != null) { Logger.Log("Voice disconnected in " + s.RegionName, Helpers.LogLevel.Info); // Inform interested parties if (OnSessionRemove != null) OnSessionRemove(s, null); if (s == spatialSession) spatialSession = null; } // The previous session is now ended. Check for a new one and // start it going. if (nextParcelCap != null) { currentParcelCap = nextParcelCap; nextParcelCap = null; RequestParcelInfo(currentParcelCap); } break; } } /// /// Close a voice session /// /// internal void CloseSession(string sessionHandle) { if (!sessions.ContainsKey(sessionHandle)) return; PosUpdating(false); ReportConnectionState(ConnectionState.AccountLogin); // Clean up spatial pointers. VoiceSession s = sessions[sessionHandle]; if (s.IsSpatial) { spatialSession = null; currentParcelCap = null; } // Remove this session from the master session list sessions.Remove(sessionHandle); // Let any user-facing code clean up. if (OnSessionRemove != null) OnSessionRemove(s, null); // Tell SLVoice to clean it up as well. SessionTerminate(sessionHandle); } /// /// Locate a Session context from its handle /// /// Creates the session context if it does not exist. VoiceSession FindSession(string sessionHandle, bool make) { if (sessions.ContainsKey(sessionHandle)) return sessions[sessionHandle]; if (!make) return null; // Create a new session and add it to the sessions list. VoiceSession s = new VoiceSession(this, sessionHandle); // Turn on position updating for spatial sessions // (For now, only spatial sessions are supported) if (s.IsSpatial) PosUpdating(true); // Register the session by its handle sessions.Add(sessionHandle, s); return s; } #endregion #region MinorResponses void connector_OnAuxAudioPropertiesEvent(object sender, AudioPropertiesEventArgs e) { if (OnVoiceMicTest != null) OnVoiceMicTest(e.MicEnergy); } #endregion private void ReportConnectionState(ConnectionState s) { if (OnVoiceConnectionChange == null) return; OnVoiceConnectionChange(s); } /// /// Handle completion of main voice cap request. /// /// /// /// void cClient_OnComplete(OpenMetaverse.Http.CapsClient client, OpenMetaverse.StructuredData.OSD result, Exception error) { if (error != null) { Logger.Log("Voice cap error " + error.Message, Helpers.LogLevel.Error); return; } Logger.Log("Voice provisioned", Helpers.LogLevel.Info); ReportConnectionState(ConnectionState.Provisioned); OpenMetaverse.StructuredData.OSDMap pMap = result as OpenMetaverse.StructuredData.OSDMap; // We can get back 4 interesting values: // voice_sip_uri_hostname // voice_account_server_name (actually a full URI) // username // password if (pMap.ContainsKey("voice_sip_uri_hostname")) sipServer = pMap["voice_sip_uri_hostname"].AsString(); if (pMap.ContainsKey("voice_account_server_name")) acctServer = pMap["voice_account_server_name"].AsString(); voiceUser = pMap["username"].AsString(); voicePassword = pMap["password"].AsString(); // Start the SLVoice daemon slvoicePath = GetVoiceDaemonPath(); // Test if the executable exists if (!System.IO.File.Exists(slvoicePath)) { Logger.Log("SLVoice is missing", Helpers.LogLevel.Error); return; } // STEP 1 StartDaemon(slvoicePath, slvoiceArgs); } #region Daemon void connector_OnDaemonCouldntConnect() { Logger.Log("No voice daemon connect", Helpers.LogLevel.Error); } void connector_OnDaemonCouldntRun() { Logger.Log("Daemon not started", Helpers.LogLevel.Error); } /// /// Daemon has started so connect to it. /// void connector_OnDaemonRunning() { OnDaemonRunning -= new VoiceGateway.DaemonRunningCallback(connector_OnDaemonRunning); Logger.Log("Daemon started", Helpers.LogLevel.Info); ReportConnectionState(ConnectionState.DaemonStarted); // STEP 2 ConnectToDaemon(daemonNode, daemonPort); } /// /// The daemon TCP connection is open. /// void connector_OnDaemonConnected() { Logger.Log("Daemon connected", Helpers.LogLevel.Info); ReportConnectionState(ConnectionState.DaemonConnected); // The connector is what does the logging. VoiceGateway.VoiceLoggingSettings vLog = new VoiceGateway.VoiceLoggingSettings(); #if DEBUG_VOICE vLog.Enabled = true; vLog.FileNamePrefix = "OpenmetaverseVoice"; vLog.FileNameSuffix = ".log"; vLog.LogLevel = 4; #endif // STEP 3 int reqId = ConnectorCreate( "V2 SDK", // Magic value keeps SLVoice happy acctServer, // Account manager server 30000, 30099, // port range vLog); if (reqId < 0) { Logger.Log("No voice connector request", Helpers.LogLevel.Error); } } /// /// Handle creation of the Connector. /// void connector_OnConnectorCreateResponse( object sender, VoiceGateway.VoiceConnectorEventArgs e) { Logger.Log("Voice daemon protocol started " + e.Message, Helpers.LogLevel.Info); connectionHandle = e.Handle; if (e.StatusCode != 0) return; // STEP 4 AccountLogin( connectionHandle, voiceUser, voicePassword, "VerifyAnswer", // This can also be "AutoAnswer" "", // Default account management server URI 10, // Throttle state changes true); // Enable buddies and presence } #endregion void connector_OnAccountLoginResponse( object sender, VoiceGateway.VoiceAccountEventArgs e) { Logger.Log("Account Login " + e.Message, Helpers.LogLevel.Info); accountHandle = e.AccountHandle; ReportConnectionState(ConnectionState.AccountLogin); ParcelChanged(); } #region Audio devices /// /// Handle response to audio output device query /// void connector_OnAuxGetRenderDevicesResponse( object sender, VoiceGateway.VoiceDevicesEventArgs e) { outputDevices = e.Devices; currentPlaybackDevice = e.CurrentDevice; } /// /// Handle response to audio input device query /// void connector_OnAuxGetCaptureDevicesResponse( object sender, VoiceGateway.VoiceDevicesEventArgs e) { inputDevices = e.Devices; currentCaptureDevice = e.CurrentDevice; } public string CurrentCaptureDevice { get { return currentCaptureDevice; } set { currentCaptureDevice = value; AuxSetCaptureDevice(value); } } public string PlaybackDevice { get { return currentPlaybackDevice; } set { currentPlaybackDevice = value; AuxSetRenderDevice(value); } } public int MicLevel { set { ConnectorSetLocalMicVolume(connectionHandle, value); } } public int SpkrLevel { set { ConnectorSetLocalSpeakerVolume(connectionHandle, value); } } public bool MicMute { set { ConnectorMuteLocalMic(connectionHandle, value); } } public bool SpkrMute { set { ConnectorMuteLocalSpeaker(connectionHandle, value); } } /// /// Set audio test mode /// public bool TestMode { get { return testing; } set { testing = value; if (testing) { if (spatialSession != null) { spatialSession.Close(); spatialSession = null; } AuxCaptureAudioStart(0); } else { AuxCaptureAudioStop(); ParcelChanged(); } } } #endregion /// /// Set voice channel for new parcel /// /// internal void ParcelChanged() { // Get the capability for this parcel. Caps c = Client.Network.CurrentSim.Caps; System.Uri pCap = c.CapabilityURI("ParcelVoiceInfoRequest"); if (pCap == null) { Logger.Log("Null voice capability", Helpers.LogLevel.Error); return; } // Parcel has changed. If we were already in a spatial session, we have to close it first. if (spatialSession != null) { nextParcelCap = pCap; CloseSession(spatialSession.Handle); } // Not already in a session, so can start the new one. RequestParcelInfo(pCap); } private OpenMetaverse.Http.CapsClient parcelCap; /// /// Request info from a parcel capability Uri. /// /// void RequestParcelInfo(Uri cap) { Logger.Log("Requesting region voice info", Helpers.LogLevel.Info); parcelCap = new OpenMetaverse.Http.CapsClient(cap); parcelCap.OnComplete += new OpenMetaverse.Http.CapsClient.CompleteCallback(pCap_OnComplete); OSD postData = new OSD(); currentParcelCap = cap; parcelCap.BeginGetResponse(postData, OSDFormat.Xml, 10000); } /// /// Receive parcel voice cap /// /// /// /// void pCap_OnComplete(OpenMetaverse.Http.CapsClient client, OpenMetaverse.StructuredData.OSD result, Exception error) { parcelCap.OnComplete -= new OpenMetaverse.Http.CapsClient.CompleteCallback(pCap_OnComplete); parcelCap = null; if (error != null) { Logger.Log("Region voice cap " + error.Message, Helpers.LogLevel.Error); return; } OpenMetaverse.StructuredData.OSDMap pMap = result as OpenMetaverse.StructuredData.OSDMap; regionName = pMap["region_name"].AsString(); ReportConnectionState(ConnectionState.RegionCapAvailable); if (pMap.ContainsKey("voice_credentials")) { OpenMetaverse.StructuredData.OSDMap cred = pMap["voice_credentials"] as OpenMetaverse.StructuredData.OSDMap; if (cred.ContainsKey("channel_uri")) spatialUri = cred["channel_uri"].AsString(); if (cred.ContainsKey("channel_credentials")) spatialCredentials = cred["channel_credentials"].AsString(); } if (spatialUri == null || spatialUri == "") { // "No voice chat allowed here"); return; } Logger.Log("Voice connecting for region " + regionName, Helpers.LogLevel.Info); // STEP 5 int reqId = SessionCreate( accountHandle, spatialUri, // uri "", // Channel name seems to be always null spatialCredentials, // spatialCredentials, // session password true, // Join Audio false, // Join Text ""); if (reqId < 0) { Logger.Log("Voice Session ReqID " + reqId.ToString(), Helpers.LogLevel.Error); } } #region Location Update /// /// Tell Vivox where we are standing /// /// This has to be called when we move or turn. internal void UpdatePosition(AgentManager self) { // Get position in Global coordinates Vector3d OMVpos = new Vector3d(self.GlobalPosition); // Do not send trivial updates. if (OMVpos.ApproxEquals(oldPosition, 1.0)) return; oldPosition = OMVpos; // Convert to the coordinate space that Vivox uses // OMV X is East, Y is North, Z is up // VVX X is East, Y is up, Z is South position.Position = new Vector3d(OMVpos.X, OMVpos.Z, -OMVpos.Y); // TODO Rotate these two vectors // Get azimuth from the facing Quaternion. // By definition, facing.W = Cos( angle/2 ) double angle = 2.0 * Math.Acos(self.Movement.BodyRotation.W); position.LeftOrientation = new Vector3d(-1.0, 0.0, 0.0); position.AtOrientation = new Vector3d((float)Math.Acos(angle), 0.0, -(float)Math.Asin(angle)); SessionSet3DPosition( sessionHandle, position, position); } /// /// Start and stop updating out position. /// /// internal void PosUpdating(bool go) { if (go) posRestart.Set(); else posRestart.Reset(); } private void PositionThreadBody() { while (true) { posRestart.WaitOne(); Thread.Sleep(1500); UpdatePosition(Client.Self); } } #endregion } }