diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index c5564ff4..32476252 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -403,8 +403,7 @@ export class ConnectionHandler { return; } if(this.serverFeatures.supportsFeature(ServerFeature.WHISPER_ECHO)) { - /* FIXME: Reenable */ - //spawnEchoTestModal(this); + spawnEchoTestModal(this); } }); } diff --git a/web/app/legacy/voice/VoiceHandler.ts b/web/app/legacy/voice/VoiceHandler.ts index 16cd6cfc..3d5ea786 100644 --- a/web/app/legacy/voice/VoiceHandler.ts +++ b/web/app/legacy/voice/VoiceHandler.ts @@ -500,7 +500,7 @@ export class VoiceConnection extends AbstractVoiceConnection { private async doStartWhisper(target: CancelableWhisperTarget) { if(target.target === "echo") { - await this.connection.send_command("setwhispertarget", { + await this.connection.send_command("setwhispersession", { type: 0x10, /* self */ target: 0, id: 0 @@ -528,7 +528,7 @@ export class VoiceConnection extends AbstractVoiceConnection { if(this.whisperTarget) { this.whisperTarget.canceled = true; this.whisperTargetInitialize = undefined; - this.connection.send_command("clearwhispertarget").catch(error => { + this.connection.send_command("clearwhispersession").catch(error => { logWarn(LogCategory.CLIENT, tr("Failed to clear the whisper target: %o"), error); }); } diff --git a/web/app/legacy/voice/VoiceWhisper.ts b/web/app/legacy/voice/VoiceWhisper.ts index a4528631..759583ee 100644 --- a/web/app/legacy/voice/VoiceWhisper.ts +++ b/web/app/legacy/voice/VoiceWhisper.ts @@ -81,6 +81,7 @@ export class WebWhisperSession implements WhisperSession { this.sessionTimeout = data.sessionTimeout; this.voicePlayer = new WebVoicePlayer(); + this.voicePlayer.setVolume(data.volume); this.voicePlayer.events.on("notify_state_changed", event => { if(event.newState === VoicePlayerState.BUFFERING) { return; diff --git a/web/app/rtc/Connection.ts b/web/app/rtc/Connection.ts index 9301f8c7..52677341 100644 --- a/web/app/rtc/Connection.ts +++ b/web/app/rtc/Connection.ts @@ -16,6 +16,7 @@ import { import {SdpCompressor, SdpProcessor} from "tc-backend/web/rtc/SdpUtils"; import {context} from "tc-backend/web/audio/player"; import {ErrorCode} from "tc-shared/connection/ErrorCode"; +import {WhisperTarget} from "tc-shared/voice/VoiceWhisper"; const kSdpCompressionMode = 1; @@ -628,6 +629,28 @@ export class RTCConnection { } } + public async startWhisper(target: WhisperTarget) : Promise { + const transceiver = this.currentTransceiver["audio-whisper"]; + if(typeof transceiver === "undefined") { + throw tr("missing transceiver"); + } + + if(target.target === "echo") { + await this.connection.send_command("whispersessioninitialize", { + ssrc: this.sdpProcessor.getLocalSsrcFromFromMediaId(transceiver.mid), + type: 0x10, /* self */ + target: 0, + id: 0 + }, { flagset: ["new"] }); + } else if(target.target === "channel-clients") { + throw "target not yet supported"; + } else if(target.target === "groups") { + throw "target not yet supported"; + } else { + throw "target not yet supported"; + } + } + public stopTrackBroadcast(type: RTCBroadcastableTrackType) { this.connection.send_command("rtcbroadcast", { type: broadcastableTrackTypeToNumber(type), diff --git a/web/app/rtc/voice/Connection.ts b/web/app/rtc/voice/Connection.ts index 7d840c1e..9646a1b2 100644 --- a/web/app/rtc/voice/Connection.ts +++ b/web/app/rtc/voice/Connection.ts @@ -5,17 +5,24 @@ import { } from "tc-shared/connection/VoiceConnection"; import {RecorderProfile} from "tc-shared/voice/RecorderProfile"; import {VoiceClient} from "tc-shared/voice/VoiceClient"; -import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper"; +import { + kUnknownWhisperClientUniqueId, + WhisperSession, + WhisperSessionState, + WhisperTarget +} from "tc-shared/voice/VoiceWhisper"; import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection"; import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; import {VoicePlayerState} from "tc-shared/voice/VoicePlayer"; import * as log from "tc-shared/log"; -import {LogCategory, logError, logTrace, logWarn} from "tc-shared/log"; +import {LogCategory, logDebug, logError, logTrace, logWarn} from "tc-shared/log"; import * as aplayer from "../../audio/player"; import {tr} from "tc-shared/i18n/localize"; import {RtpVoiceClient} from "tc-backend/web/rtc/voice/VoiceClient"; import {InputConsumerType} from "tc-shared/voice/RecorderBase"; +import {RtpWhisperSession} from "tc-backend/web/rtc/voice/WhisperClient"; +type CancelableWhisperTarget = WhisperTarget & { canceled: boolean }; export class RtpVoiceConnection extends AbstractVoiceConnection { private readonly rtcConnection: RTCConnection; @@ -34,16 +41,21 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { private speakerMuted: boolean; private voiceClients: RtpVoiceClient[] = []; + private whisperSessionInitializer: WhisperSessionInitializer | undefined; + private whisperSessions: RtpWhisperSession[] = []; + private whisperTarget: CancelableWhisperTarget | undefined; + private whisperTargetInitialize: Promise | undefined; + private currentlyReplayingVoice: boolean = false; private readonly voiceClientStateChangedEventListener; private readonly whisperSessionStateChangedEventListener; - constructor(connection: AbstractServerConnection, rtcConnection: RTCConnection) { super(connection); this.rtcConnection = rtcConnection; this.voiceClientStateChangedEventListener = this.handleVoiceClientStateChange.bind(this); + this.whisperSessionStateChangedEventListener = this.handleWhisperSessionStateChange.bind(this); this.rtcConnection.getEvents().on("notify_audio_assignment_changed", this.listenerRtcAudioAssignment = event => this.handleAudioAssignmentChanged(event)); @@ -78,6 +90,8 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { this.currentAudioSourceNode.connect(this.localAudioDestination); } }); + + this.setWhisperSessionInitializer(undefined); } destroy() { @@ -186,17 +200,23 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { const ch = chandler.getClient(); if(ch) ch.speaking = false; - if(!chandler.connected) + if(!chandler.connected) { return false; + } - if(chandler.isMicrophoneMuted()) + if(chandler.isMicrophoneMuted()) { return false; + } log.info(LogCategory.VOICE, tr("Local voice ended")); this.rtcConnection.setTrackSource("audio", null).catch(error => { logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); }); + + this.rtcConnection.setTrackSource("audio-whisper", null).catch(error => { + logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); + }); } private handleRecorderStart() { @@ -210,7 +230,8 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { const ch = chandler.getClient(); if(ch) { ch.speaking = true; } - this.rtcConnection.setTrackSource("audio", this.localAudioDestination.stream.getAudioTracks()[0]) + + this.rtcConnection.setTrackSource(this.whisperTarget ? "audio-whisper" : "audio", this.localAudioDestination.stream.getAudioTracks()[0]) .catch(error => { logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error); }); @@ -235,10 +256,12 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } getEncoderCodec(): number { - return 5; + return 4; } - setEncoderCodec(codec: number) { } + setEncoderCodec(codec: number) { + /* TODO: If possible change the payload format? */ + } availableVoiceClients(): VoiceClient[] { return Object.values(this.voiceClients); @@ -266,33 +289,93 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { client.destroy(); } - stopAllVoiceReplays() { - } - + stopAllVoiceReplays() { } getWhisperSessionInitializer(): WhisperSessionInitializer | undefined { - return undefined; + return this.whisperSessionInitializer; } setWhisperSessionInitializer(initializer: WhisperSessionInitializer | undefined) { + this.whisperSessionInitializer = initializer; + if(!this.whisperSessionInitializer) { + this.whisperSessionInitializer = async session => { + logWarn(LogCategory.VOICE, tr("Missing whisper session initializer. Blocking whisper from %d (%s)"), session.getClientId(), session.getClientUniqueId()); + return { + clientName: session.getClientName() || tr("Unknown client"), + clientUniqueId: session.getClientUniqueId() || kUnknownWhisperClientUniqueId, + + blocked: true, + volume: 1, + + sessionTimeout: 60 * 1000 + } + } + } } getWhisperSessions(): WhisperSession[] { - return []; - } - - getWhisperTarget(): WhisperTarget | undefined { - return undefined; + return this.whisperSessions; } dropWhisperSession(session: WhisperSession) { + if(!(session instanceof RtpWhisperSession)) { + throw tr("Invalid session type"); + } + + session.events.off("notify_state_changed", this.whisperSessionStateChangedEventListener); + this.whisperSessions.remove(session); + session.destroy(); } - startWhisper(target: WhisperTarget): Promise { - return Promise.resolve(undefined); + async startWhisper(target: WhisperTarget): Promise { + while(this.whisperTargetInitialize) { + this.whisperTarget.canceled = true; + await this.whisperTargetInitialize; + } + + this.whisperTarget = Object.assign({ canceled: false }, target); + try { + await (this.whisperTargetInitialize = this.doStartWhisper(this.whisperTarget)); + } finally { + this.whisperTargetInitialize = undefined; + } } - stopWhisper() { } + private async doStartWhisper(target: CancelableWhisperTarget) { + if(this.rtcConnection.getConnectionState() !== RTPConnectionState.CONNECTED) { + return; + } + + await this.rtcConnection.startWhisper(target); + + if(target.canceled) { + return; + } + + this.handleRecorderStop(); + if(this.currentAudioSource?.input && !this.currentAudioSource.input.isFiltered()) { + this.handleRecorderStart(); + } + } + + getWhisperTarget(): WhisperTarget | undefined { + return this.whisperTarget; + } + + stopWhisper() { + if(this.whisperTarget) { + this.whisperTarget.canceled = true; + this.whisperTargetInitialize = undefined; + this.connection.send_command("whispersessionreset").catch(error => { + logWarn(LogCategory.CLIENT, tr("Failed to clear the whisper target: %o"), error); + }); + } + + this.handleRecorderStop(); + if(this.currentAudioSource?.input && !this.currentAudioSource.input.isFiltered()) { + this.handleRecorderStart(); + } + } private handleVoiceClientStateChange(/* event: VoicePlayerEvents["notify_state_changed"] */) { this.updateVoiceReplaying(); @@ -303,9 +386,9 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } private updateVoiceReplaying() { - let replaying = false; + let replaying; - /* if(this.connectionState === VoiceConnectionStatus.Connected) */ { + { let index = this.availableVoiceClients().findIndex(client => client.getState() === VoicePlayerState.PLAYING || client.getState() === VoicePlayerState.BUFFERING); replaying = index !== -1; @@ -322,8 +405,6 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } } - - private setConnectionState(state: VoiceConnectionStatus) { if(this.connectionState === state) return; @@ -344,6 +425,12 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { this.localFailedReason = tr("Failed to start audio broadcasting"); this.setConnectionState(VoiceConnectionStatus.Failed); }); + if(this.whisperTarget) { + this.startWhisper(this.whisperTarget).catch(error => { + logError(LogCategory.VOICE, tr("Failed to start voice whisper on connected rtc connection: &o"), error); + /* TODO: Somehow abort the whisper and give the user a feedback? */ + }); + } break; case RTPConnectionState.CONNECTING: @@ -366,17 +453,62 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } private handleAudioAssignmentChanged(event: RTCConnectionEvents["notify_audio_assignment_changed"]) { - const oldClient = Object.values(this.voiceClients).find(client => client.getRtpTrack() === event.track); - if(oldClient) { - oldClient.setRtpTrack(undefined); + { + let oldClient = Object.values(this.voiceClients).find(client => client.getRtpTrack() === event.track); + oldClient?.setRtpTrack(undefined); + + let oldSession = this.whisperSessions.find(e => e.getRtpTrack() === event.track); + oldSession?.setRtpTrack(undefined); } if(event.info) { - const newClient = this.voiceClients[event.info.client_id]; - if(newClient) { - newClient.setRtpTrack(event.track); - } else { - logWarn(LogCategory.AUDIO, tr("Received audio track assignment for unknown voice client (%o)."), event.info); + if(typeof event.info.media === "undefined") { + logWarn(LogCategory.VOICE, tr("Received audio assignment event with missing media type")); + return; + } + + switch (event.info.media) { + case 0: { + const newClient = this.voiceClients[event.info.client_id]; + if(newClient) { + newClient.setRtpTrack(event.track); + } else { + logWarn(LogCategory.AUDIO, tr("Received audio track assignment for unknown voice client (%o)."), event.info); + } + break; + } + case 1: { + let session = this.whisperSessions.find(e => e.getClientId() === event.info.client_id); + if(typeof session === "undefined") { + logDebug(LogCategory.VOICE, tr("Received new whisper from %d (%s)"), event.info.client_id, event.info.client_name); + session = new RtpWhisperSession(event.track, event.info); + session.setGloballyMuted(this.speakerMuted); + this.whisperSessions.push(session); + session.events.on("notify_state_changed", this.whisperSessionStateChangedEventListener); + this.whisperSessionInitializer(session).then(result => { + try { + session.initializeFromData(result); + this.events.fire("notify_whisper_initialized", { session }); + } catch (error) { + logError(LogCategory.VOICE, tr("Failed to internally initialize a voice whisper session: %o"), error); + session.setSessionState(WhisperSessionState.INITIALIZE_FAILED); + } + }).catch(error => { + logError(LogCategory.VOICE, tr("Failed to initialize whisper session: %o."), error); + session.initializeFailed(); + }); + + session.events.on("notify_timed_out", () => { + logTrace(LogCategory.VOICE, tr("Whisper session %d timed out. Dropping session."), session.getClientId()); + this.dropWhisperSession(session); + }); + this.events.fire("notify_whisper_created", { session: session }); + } + break; + } + default: + logWarn(LogCategory.VOICE, tr("Received audio assignment event with invalid media type (%o)"), event.info.media); + break; } } } @@ -396,6 +528,7 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { this.speakerMuted = newState; this.voiceClients.forEach(client => client.setGloballyMuted(this.speakerMuted)); + this.whisperSessions.forEach(session => session.setGloballyMuted(this.speakerMuted)); } getRetryTimestamp(): number | 0 { diff --git a/web/app/rtc/voice/RtpVoicePlayer.ts b/web/app/rtc/voice/RtpVoicePlayer.ts new file mode 100644 index 00000000..cb3d2e45 --- /dev/null +++ b/web/app/rtc/voice/RtpVoicePlayer.ts @@ -0,0 +1,115 @@ +import { + VoicePlayer, + VoicePlayerEvents, + VoicePlayerLatencySettings, + VoicePlayerState +} from "tc-shared/voice/VoicePlayer"; +import {Registry} from "tc-shared/events"; +import {LogCategory, logWarn} from "tc-shared/log"; +import {RemoteRTPAudioTrack, RemoteRTPTrackState} from "tc-backend/web/rtc/RemoteTrack"; + +export interface RtpVoicePlayerEvents { + notify_state_changed: { oldState: VoicePlayerState, newState: VoicePlayerState } +} + +export class RtpVoicePlayer implements VoicePlayer { + readonly events: Registry; + private readonly listenerTrackStateChanged; + + private globallyMuted: boolean; + + private volume: number; + private currentState: VoicePlayerState; + private currentRtpTrack: RemoteRTPAudioTrack; + + constructor() { + this.listenerTrackStateChanged = event => this.handleTrackStateChanged(event.newState); + this.events = new Registry(); + this.currentState = VoicePlayerState.STOPPED; + } + + destroy() { + this.events.destroy(); + } + + setGloballyMuted(muted: boolean) { + if(this.globallyMuted === muted) { return; } + + this.globallyMuted = muted; + this.updateVolume(); + } + + abortReplay() { + this.currentRtpTrack?.abortCurrentReplay(); + this.setState(VoicePlayerState.STOPPED); + } + + getState(): VoicePlayerState { + return this.currentState; + } + + protected setState(state: VoicePlayerState) { + if(this.currentState === state) { return; } + + const oldState = this.currentState; + this.currentState = state; + this.events.fire("notify_state_changed", { oldState: oldState, newState: state }); + } + + getVolume(): number { + return this.volume; + } + + setVolume(volume: number) { + this.volume = volume; + this.updateVolume(); + } + + setRtpTrack(track: RemoteRTPAudioTrack | undefined) { + if(this.currentRtpTrack) { + this.currentRtpTrack.setGain(0); + this.currentRtpTrack.getEvents().off("notify_state_changed", this.listenerTrackStateChanged); + } + + this.currentRtpTrack = track; + if(this.currentRtpTrack) { + this.currentRtpTrack.getEvents().on("notify_state_changed", this.listenerTrackStateChanged); + this.updateVolume(); + this.handleTrackStateChanged(this.currentRtpTrack.getState()); + } + } + + getRtpTrack() { + return this.currentRtpTrack; + } + + private handleTrackStateChanged(newState: RemoteRTPTrackState) { + switch (newState) { + case RemoteRTPTrackState.Bound: + case RemoteRTPTrackState.Unbound: + this.setState(VoicePlayerState.STOPPED); + break; + + case RemoteRTPTrackState.Started: + this.setState(VoicePlayerState.PLAYING); + break; + + case RemoteRTPTrackState.Destroyed: + logWarn(LogCategory.AUDIO, tr("Received new track state 'Destroyed' which should never happen.")); + this.setState(VoicePlayerState.STOPPED); + break; + } + } + + private updateVolume() { + this.currentRtpTrack?.setGain(this.globallyMuted ? 0 : this.volume); + } + + flushBuffer() { /* not supported */ } + + getLatencySettings(): Readonly { + return { minBufferTime: 0, maxBufferTime: 0 }; + } + resetLatencySettings() { } + setLatencySettings(settings: VoicePlayerLatencySettings) { } +} \ No newline at end of file diff --git a/web/app/rtc/voice/VoiceClient.ts b/web/app/rtc/voice/VoiceClient.ts index e4869293..690ab26f 100644 --- a/web/app/rtc/voice/VoiceClient.ts +++ b/web/app/rtc/voice/VoiceClient.ts @@ -1,111 +1,16 @@ import {VoiceClient} from "tc-shared/voice/VoiceClient"; -import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer"; -import {Registry} from "tc-shared/events"; -import {LogCategory, logWarn} from "tc-shared/log"; -import {RemoteRTPAudioTrack, RemoteRTPTrackState} from "tc-backend/web/rtc/RemoteTrack"; +import {RtpVoicePlayer} from "./RtpVoicePlayer"; -export class RtpVoiceClient implements VoiceClient { - readonly events: Registry; - private readonly listenerTrackStateChanged; +export class RtpVoiceClient extends RtpVoicePlayer implements VoiceClient { private readonly clientId: number; - private globallyMuted: boolean; - - private volume: number; - private currentState: VoicePlayerState; - private currentRtpTrack: RemoteRTPAudioTrack; - constructor(clientId: number) { + super(); + this.clientId = clientId; - this.listenerTrackStateChanged = event => this.handleTrackStateChanged(event.newState); - this.events = new Registry(); - this.currentState = VoicePlayerState.STOPPED; - } - - destroy() { - this.events.destroy(); - } - - setGloballyMuted(muted: boolean) { - if(this.globallyMuted === muted) { return; } - - this.globallyMuted = muted; - this.updateVolume(); } getClientId(): number { return this.clientId; } - - abortReplay() { - this.currentRtpTrack?.abortCurrentReplay(); - this.setState(VoicePlayerState.STOPPED); - } - - flushBuffer() { /* not possible */ } - - getState(): VoicePlayerState { - return this.currentState; - } - - protected setState(state: VoicePlayerState) { - if(this.currentState === state) { return; } - const oldState = this.currentState; - this.currentState = state; - this.events.fire("notify_state_changed", { oldState: oldState, newState: state }); - } - - getVolume(): number { - return this.volume; - } - - setVolume(volume: number) { - this.volume = volume; - this.updateVolume(); - } - - getLatencySettings(): Readonly { - return { minBufferTime: 0, maxBufferTime: 0 }; - } - resetLatencySettings() { } - setLatencySettings(settings: VoicePlayerLatencySettings) { } - - setRtpTrack(track: RemoteRTPAudioTrack | undefined) { - if(this.currentRtpTrack) { - this.currentRtpTrack.setGain(0); - this.currentRtpTrack.getEvents().off("notify_state_changed", this.listenerTrackStateChanged); - } - this.currentRtpTrack = track; - if(this.currentRtpTrack) { - this.currentRtpTrack.setGain(this.volume); - this.currentRtpTrack.getEvents().on("notify_state_changed", this.listenerTrackStateChanged); - this.handleTrackStateChanged(this.currentRtpTrack.getState()); - } - } - - getRtpTrack() { - return this.currentRtpTrack; - } - - private handleTrackStateChanged(newState: RemoteRTPTrackState) { - switch (newState) { - case RemoteRTPTrackState.Bound: - case RemoteRTPTrackState.Unbound: - this.setState(VoicePlayerState.STOPPED); - break; - - case RemoteRTPTrackState.Started: - this.setState(VoicePlayerState.PLAYING); - break; - - case RemoteRTPTrackState.Destroyed: - logWarn(LogCategory.AUDIO, tr("Received new track state 'Destroyed' which should never happen.")); - this.setState(VoicePlayerState.STOPPED); - break; - } - } - - private updateVolume() { - this.currentRtpTrack?.setGain(this.globallyMuted ? 0 : this.volume); - } } \ No newline at end of file diff --git a/web/app/rtc/voice/WhisperClient.ts b/web/app/rtc/voice/WhisperClient.ts index e69de29b..6f5d683a 100644 --- a/web/app/rtc/voice/WhisperClient.ts +++ b/web/app/rtc/voice/WhisperClient.ts @@ -0,0 +1,167 @@ +import {WhisperSession, WhisperSessionEvents, WhisperSessionState} from "tc-shared/voice/VoiceWhisper"; +import {Registry} from "tc-shared/events"; +import {VoicePlayer, VoicePlayerState} from "tc-shared/voice/VoicePlayer"; +import {WhisperSessionInitializeData} from "tc-shared/connection/VoiceConnection"; +import {RtpVoicePlayer} from "./RtpVoicePlayer"; +import {RemoteRTPAudioTrack, TrackClientInfo} from "tc-backend/web/rtc/RemoteTrack"; + +export class RtpWhisperSession implements WhisperSession { + readonly events: Registry; + private readonly assignmentInfo: TrackClientInfo; + + private track: RemoteRTPAudioTrack; + private globallyMuted: boolean; + + private sessionState: WhisperSessionState; + private sessionBlocked: boolean; + + private sessionTimeout: number; + private sessionTimeoutId: number; + + private lastWhisperTimestamp: number; + private voicePlayer: RtpVoicePlayer; + + constructor(track: RemoteRTPAudioTrack, info: TrackClientInfo) { + this.events = new Registry(); + this.track = track; + this.assignmentInfo = info; + this.sessionState = WhisperSessionState.INITIALIZING; + + this.globallyMuted = false; + } + + getClientId(): number { + return this.assignmentInfo.client_id; + } + + getClientName(): string | undefined { + return this.assignmentInfo.client_name; + } + + getClientUniqueId(): string | undefined { + return this.assignmentInfo.client_unique_id; + } + + getLastWhisperTimestamp(): number { + return this.lastWhisperTimestamp; + } + + getSessionState(): WhisperSessionState { + return this.sessionState; + } + + getSessionTimeout(): number { + return this.sessionTimeout; + } + + getVoicePlayer(): VoicePlayer | undefined { + return this.voicePlayer; + } + + setSessionTimeout(timeout: number) { + this.sessionTimeout = timeout; + this.resetSessionTimeout(); + } + + isBlocked(): boolean { + return this.sessionBlocked; + } + + setBlocked(blocked: boolean) { + this.sessionBlocked = blocked; + } + + initializeFromData(data: WhisperSessionInitializeData) { + this.assignmentInfo.client_name = data.clientName; + this.assignmentInfo.client_unique_id = data.clientUniqueId; + + this.sessionBlocked = data.blocked; + this.sessionTimeout = data.sessionTimeout; + + this.voicePlayer = new RtpVoicePlayer(); + this.voicePlayer.setRtpTrack(this.track); + this.voicePlayer.setGloballyMuted(this.globallyMuted); + this.voicePlayer.setVolume(data.volume); + this.voicePlayer.events.on("notify_state_changed", event => { + if(event.newState === VoicePlayerState.BUFFERING) { + return; + } + + this.resetSessionTimeout(); + if(event.newState === VoicePlayerState.PLAYING || event.newState === VoicePlayerState.STOPPING) { + this.setSessionState(WhisperSessionState.PLAYING); + } else { + this.setSessionState(WhisperSessionState.PAUSED); + } + }); + this.setSessionState(WhisperSessionState.PAUSED); + } + + initializeFailed() { + this.setSessionState(WhisperSessionState.INITIALIZE_FAILED); + + /* if we're receiving nothing for more than 5 seconds we can try it again */ + this.sessionTimeout = 5000; + this.resetSessionTimeout(); + } + + destroy() { + clearTimeout(this.sessionTimeoutId); + this.events.destroy(); + this.voicePlayer?.destroy(); + this.voicePlayer = undefined; + } + + setSessionState(state: WhisperSessionState) { + if(this.sessionState === state) { + return; + } + + const oldState = this.sessionState; + this.sessionState = state; + this.events.fire("notify_state_changed", { oldState: oldState, newState: state }); + } + + private resetSessionTimeout() { + clearTimeout(this.sessionTimeoutId); + if(this.sessionState === WhisperSessionState.PLAYING) { + /* no need to reschedule a session timeout if we're currently playing */ + return; + } else if(this.sessionState === WhisperSessionState.INITIALIZING) { + /* we're still initializing; a session timeout hasn't been set */ + return; + } + + this.sessionTimeoutId = setTimeout(() => { + this.events.fire("notify_timed_out"); + }, Math.max(this.sessionTimeout, 1000)); + } + + setRtpTrack(track: RemoteRTPAudioTrack | undefined) { + if(track) { + const info = track.getCurrentAssignment(); + if(!info) { + throw tr("Target track missing an assignment"); + } + + if(info.client_id !== this.assignmentInfo.client_id) { + throw tra("Target track does not belong to current whisper session owner (Owner: {}, Track owner: {})", this.assignmentInfo.client_id, info.client_name); + } + + this.assignmentInfo.client_name = info.client_name; + this.assignmentInfo.client_unique_id = info.client_unique_id; + } + + this.track = track; + this.voicePlayer?.setRtpTrack(track); + } + + getRtpTrack() { + return this.voicePlayer.getRtpTrack(); + } + + setGloballyMuted(muted: boolean) { + this.globallyMuted = muted; + this.voicePlayer?.setGloballyMuted(muted); + } +} \ No newline at end of file