2018-03-07 19:06:52 +01:00
/// <reference path="../client.ts" />
2018-03-24 23:38:01 +01:00
/// <reference path="../codec/Codec.ts" />
2018-03-07 19:06:52 +01:00
/// <reference path="VoiceRecorder.ts" />
2018-04-11 17:56:09 +02:00
class CodecPoolEntry {
instance : BasicCodec ;
owner : number ;
last_access : number ;
}
class CodecPool {
handle : VoiceConnection ;
codecIndex : number ;
2018-04-19 18:42:34 +02:00
name : string ;
2018-04-11 17:56:09 +02:00
creator : ( ) = > BasicCodec ;
entries : CodecPoolEntry [ ] = [ ] ;
maxInstances : number = 2 ;
2018-04-19 18:42:34 +02:00
private _supported : boolean = true ;
2018-04-16 20:38:35 +02:00
initialize ( cached : number ) {
for ( let i = 0 ; i < cached ; i ++ )
2018-04-18 20:12:10 +02:00
this . ownCodec ( i + 1 ) . then ( codec = > {
console . log ( "Release again! (%o)" , codec ) ;
this . releaseCodec ( i + 1 ) ;
2018-04-19 18:42:34 +02:00
} ) . catch ( error = > {
if ( this . _supported ) {
createErrorModal ( "Could not load codec driver" , "Could not load or initialize codec " + this . name + "<br>" +
"Error: <code>" + JSON . stringify ( error ) + "</code>" ) . open ( ) ;
}
this . _supported = false ;
console . error ( error ) ;
2018-04-18 20:12:10 +02:00
} ) ;
2018-04-16 20:38:35 +02:00
}
2018-04-19 18:42:34 +02:00
supported() { return this . creator != undefined && this . _supported ; }
2018-04-16 20:38:35 +02:00
2018-04-18 20:12:10 +02:00
ownCodec ? ( clientId : number , create : boolean = true ) : Promise < BasicCodec | undefined > {
return new Promise < BasicCodec > ( ( resolve , reject ) = > {
2018-04-19 18:42:34 +02:00
if ( ! this . creator || ! this . _supported ) {
2018-04-18 20:12:10 +02:00
reject ( "unsupported codec!" ) ;
return ;
}
2018-04-11 17:56:09 +02:00
2018-04-18 20:12:10 +02:00
let freeSlot = 0 ;
for ( let index = 0 ; index < this . entries . length ; index ++ ) {
if ( this . entries [ index ] . owner == clientId ) {
this . entries [ index ] . last_access = new Date ( ) . getTime ( ) ;
if ( this . entries [ index ] . instance . initialized ( ) ) resolve ( this . entries [ index ] . instance ) ;
2018-04-19 18:42:34 +02:00
else {
this . entries [ index ] . instance . initialise ( ) . then ( ( flag ) = > {
//TODO test success flag
this . ownCodec ( clientId , false ) . then ( resolve ) . catch ( reject ) ;
} ) . catch ( error = > {
console . error ( "Could not initialize codec!\nError: %o" , error ) ;
reject ( "Could not initialize codec!" ) ;
} ) ;
}
2018-04-18 20:12:10 +02:00
return ;
} else if ( freeSlot == 0 && this . entries [ index ] . owner == 0 ) {
freeSlot = index ;
}
2018-04-11 17:56:09 +02:00
}
2018-04-18 20:12:10 +02:00
if ( ! create ) {
resolve ( undefined ) ;
return ;
}
if ( freeSlot == 0 ) {
freeSlot = this . entries . length ;
let entry = new CodecPoolEntry ( ) ;
entry . instance = this . creator ( ) ;
2018-06-19 20:31:05 +02:00
entry . instance . on_encoded_data = buffer = > this . handle . handleEncodedVoicePacket ( buffer , this . codecIndex ) ;
2018-04-18 20:12:10 +02:00
this . entries . push ( entry ) ;
}
this . entries [ freeSlot ] . owner = clientId ;
this . entries [ freeSlot ] . last_access = new Date ( ) . getTime ( ) ;
if ( this . entries [ freeSlot ] . instance . initialized ( ) )
this . entries [ freeSlot ] . instance . reset ( ) ;
else {
2018-04-19 18:42:34 +02:00
this . ownCodec ( clientId , false ) . then ( resolve ) . catch ( reject ) ;
2018-04-18 20:12:10 +02:00
return ;
}
resolve ( this . entries [ freeSlot ] . instance ) ;
} ) ;
2018-04-11 17:56:09 +02:00
}
releaseCodec ( clientId : number ) {
2018-04-19 18:42:34 +02:00
for ( let index = 0 ; index < this . entries . length ; index ++ )
2018-04-11 17:56:09 +02:00
if ( this . entries [ index ] . owner == clientId ) this . entries [ index ] . owner = 0 ;
}
2018-04-19 18:42:34 +02:00
constructor ( handle : VoiceConnection , index : number , name : string , creator : ( ) = > BasicCodec ) {
2018-04-11 17:56:09 +02:00
this . creator = creator ;
this . handle = handle ;
this . codecIndex = index ;
2018-04-19 18:42:34 +02:00
this . name = name ;
2018-04-11 17:56:09 +02:00
}
}
2018-09-21 23:25:03 +02:00
enum VoiceConnectionType {
JS_ENCODE ,
NATIVE_ENCODE
}
2018-09-22 16:52:26 +02:00
/* funny fact that typescript dosn't find this */
interface RTCPeerConnection {
addStream ( stream : MediaStream ) : void ;
getLocalStreams ( ) : MediaStream [ ] ;
getStreamById ( streamId : string ) : MediaStream | null ;
removeStream ( stream : MediaStream ) : void ;
createOffer ( successCallback? : RTCSessionDescriptionCallback , failureCallback? : RTCPeerConnectionErrorCallback , options? : RTCOfferOptions ) : Promise < RTCSessionDescription > ;
}
2018-03-07 19:06:52 +01:00
class VoiceConnection {
client : TSClient ;
rtcPeerConnection : RTCPeerConnection ;
dataChannel : RTCDataChannel ;
voiceRecorder : VoiceRecorder ;
2018-09-24 20:21:37 +02:00
private _type : VoiceConnectionType = VoiceConnectionType . NATIVE_ENCODE ;
2018-09-21 23:25:03 +02:00
local_audio_stream : any ;
2018-03-07 19:06:52 +01:00
2018-08-08 19:32:12 +02:00
private codec_pool : CodecPool [ ] = [
2018-04-19 18:42:34 +02:00
new CodecPool ( this , 0 , "Spex A" , undefined ) , //Spex
new CodecPool ( this , 1 , "Spex B" , undefined ) , //Spex
new CodecPool ( this , 2 , "Spex C" , undefined ) , //Spex
new CodecPool ( this , 3 , "CELT Mono" , undefined ) , //CELT Mono
2018-11-12 13:00:13 +01:00
new CodecPool ( this , 4 , "Opus Voice" , ( ) = > { return audio . codec . new_instance ( CodecType . OPUS_VOICE ) } ) , //opus voice
new CodecPool ( this , 5 , "Opus Music" , ( ) = > { return audio . codec . new_instance ( CodecType . OPUS_MUSIC ) } ) //opus music
2018-03-07 19:06:52 +01:00
] ;
private vpacketId : number = 0 ;
private chunkVPacketId : number = 0 ;
2018-10-09 01:27:14 +02:00
private send_task : NodeJS.Timer ;
2018-03-07 19:06:52 +01:00
constructor ( client ) {
this . client = client ;
2018-09-23 15:24:07 +02:00
this . _type = settings . static_global ( "voice_connection_type" , this . _type ) ;
2018-03-07 19:06:52 +01:00
this . voiceRecorder = new VoiceRecorder ( this ) ;
this . voiceRecorder . on_end = this . handleVoiceEnded . bind ( this ) ;
2018-09-22 15:14:39 +02:00
this . voiceRecorder . on_start = this . handleVoiceStarted . bind ( this ) ;
2018-04-11 17:56:09 +02:00
this . voiceRecorder . reinitialiseVAD ( ) ;
2018-04-16 20:38:35 +02:00
2018-10-28 18:25:43 +01:00
audio . player . on_ready ( ( ) = > {
2018-09-26 15:30:22 +02:00
log . info ( LogCategory . VOICE , "Initializing voice handler after AudioController has been initialized!" ) ;
2018-08-08 19:32:12 +02:00
this . codec_pool [ 4 ] . initialize ( 2 ) ;
this . codec_pool [ 5 ] . initialize ( 2 ) ;
2018-09-21 23:25:03 +02:00
2018-09-23 15:24:07 +02:00
if ( this . type == VoiceConnectionType . NATIVE_ENCODE )
this . setup_native ( ) ;
else
this . setup_js ( ) ;
2018-08-05 18:59:24 +02:00
} ) ;
2018-05-07 11:51:50 +02:00
2018-06-19 20:31:05 +02:00
this . send_task = setInterval ( this . sendNextVoicePacket . bind ( this ) , 20 ) ;
2018-04-16 20:38:35 +02:00
}
2018-09-25 17:39:38 +02:00
native_encoding_supported ( ) : boolean {
2018-09-26 15:30:22 +02:00
if ( ! ( window . webkitAudioContext || window . AudioContext || { prototype : { } } as typeof AudioContext ) . prototype . createMediaStreamDestination ) return false ; //Required, but not available within edge
2018-09-25 17:39:38 +02:00
return true ;
}
javascript_encoding_supported ( ) : boolean {
2018-09-26 15:30:22 +02:00
if ( ! ( window . RTCPeerConnection || { prototype : { } } as typeof RTCPeerConnection ) . prototype . createDataChannel ) return false ;
2018-09-25 17:39:38 +02:00
return true ;
}
current_encoding_supported ( ) : boolean {
switch ( this . _type ) {
case VoiceConnectionType . JS_ENCODE :
return this . javascript_encoding_supported ( ) ;
case VoiceConnectionType . NATIVE_ENCODE :
return this . native_encoding_supported ( ) ;
}
return false ;
}
2018-09-23 15:24:07 +02:00
private setup_native() {
2018-09-26 15:30:22 +02:00
log . info ( LogCategory . VOICE , "Setting up native voice stream!" ) ;
if ( ! this . native_encoding_supported ( ) ) {
log . warn ( LogCategory . VOICE , "Native codec isnt supported!" ) ;
return ;
}
2018-09-25 17:39:38 +02:00
2018-09-23 15:24:07 +02:00
this . voiceRecorder . on_data = undefined ;
let stream = this . voiceRecorder . get_output_stream ( ) ;
stream . disconnect ( ) ;
if ( ! this . local_audio_stream )
2018-10-28 18:25:43 +01:00
this . local_audio_stream = audio . player . context ( ) . createMediaStreamDestination ( ) ;
2018-09-23 15:24:07 +02:00
stream . connect ( this . local_audio_stream ) ;
}
private setup_js() {
2018-09-25 17:39:38 +02:00
if ( ! this . javascript_encoding_supported ( ) ) return ;
2018-09-23 15:24:07 +02:00
this . voiceRecorder . on_data = this . handleVoiceData . bind ( this ) ;
}
get type ( ) : VoiceConnectionType { return this . _type ; }
set type ( target : VoiceConnectionType ) {
if ( target == this . type ) return ;
this . _type = target ;
if ( this . type == VoiceConnectionType . NATIVE_ENCODE )
this . setup_native ( ) ;
else
this . setup_js ( ) ;
this . createSession ( ) ;
}
2018-04-16 20:38:35 +02:00
codecSupported ( type : number ) : boolean {
2018-08-08 19:32:12 +02:00
return this . codec_pool . length > type && this . codec_pool [ type ] . supported ( ) ;
}
2018-09-22 15:14:39 +02:00
voice_playback_support ( ) : boolean {
2018-08-08 19:32:12 +02:00
return this . dataChannel && this . dataChannel . readyState == "open" ;
2018-03-07 19:06:52 +01:00
}
2018-09-22 15:14:39 +02:00
voice_send_support ( ) : boolean {
if ( this . type == VoiceConnectionType . NATIVE_ENCODE )
2018-09-25 17:39:38 +02:00
return this . native_encoding_supported ( ) && this . rtcPeerConnection . getLocalStreams ( ) . length > 0 ;
2018-09-22 15:14:39 +02:00
else
return this . voice_playback_support ( ) ;
}
2018-06-19 20:31:05 +02:00
private voice_send_queue : { data : Uint8Array , codec : number } [ ] = [ ] ;
2018-05-07 11:51:50 +02:00
handleEncodedVoicePacket ( data : Uint8Array , codec : number ) {
2018-06-19 20:31:05 +02:00
this . voice_send_queue . push ( { data : data , codec : codec } ) ;
}
private sendNextVoicePacket() {
let buffer = this . voice_send_queue . pop_front ( ) ;
if ( ! buffer ) return ;
this . sendVoicePacket ( buffer . data , buffer . codec ) ;
2018-05-07 11:51:50 +02:00
}
2018-03-07 19:06:52 +01:00
sendVoicePacket ( data : Uint8Array , codec : number ) {
if ( this . dataChannel ) {
this . vpacketId ++ ;
if ( this . vpacketId > 65535 ) this . vpacketId = 0 ;
let packet = new Uint8Array ( data . byteLength + 2 + 3 ) ;
packet [ 0 ] = this . chunkVPacketId ++ < 5 ? 1 : 0 ; //Flag header
2018-04-11 17:56:09 +02:00
packet [ 1 ] = 0 ; //Flag fragmented
packet [ 2 ] = ( this . vpacketId >> 8 ) & 0xFF ; //HIGHT (voiceID)
packet [ 3 ] = ( this . vpacketId >> 0 ) & 0xFF ; //LOW (voiceID)
2018-03-07 19:06:52 +01:00
packet [ 4 ] = codec ; //Codec
packet . set ( data , 5 ) ;
2018-06-19 20:31:05 +02:00
try {
this . dataChannel . send ( packet ) ;
} catch ( e ) {
//TODO may handle error?
}
2018-03-07 19:06:52 +01:00
} else {
console . warn ( "Could not transfer audio (not connected)" ) ;
}
}
createSession() {
2018-09-25 17:39:38 +02:00
if ( ! this . current_encoding_supported ( ) ) return false ;
2018-09-23 15:24:07 +02:00
if ( this . rtcPeerConnection ) {
this . dropSession ( ) ;
}
2018-08-08 19:32:12 +02:00
this . _ice_use_cache = true ;
2018-09-21 23:25:03 +02:00
2018-08-08 19:32:12 +02:00
let config : RTCConfiguration = { } ;
config . iceServers = [ ] ;
config . iceServers . push ( { urls : 'stun:stun.l.google.com:19302' } ) ;
2018-03-07 19:06:52 +01:00
this . rtcPeerConnection = new RTCPeerConnection ( config ) ;
2018-08-12 22:31:40 +02:00
const dataChannelConfig = { ordered : true , maxRetransmits : 0 } ;
2018-03-07 19:06:52 +01:00
this . dataChannel = this . rtcPeerConnection . createDataChannel ( 'main' , dataChannelConfig ) ;
this . dataChannel . onmessage = this . onDataChannelMessage . bind ( this ) ;
this . dataChannel . onopen = this . onDataChannelOpen . bind ( this ) ;
2018-08-08 19:32:12 +02:00
this . dataChannel . binaryType = "arraybuffer" ;
2018-03-07 19:06:52 +01:00
let sdpConstraints : RTCOfferOptions = { } ;
2018-09-23 15:24:07 +02:00
sdpConstraints . offerToReceiveAudio = this . _type == VoiceConnectionType . NATIVE_ENCODE ;
2018-09-22 16:52:26 +02:00
sdpConstraints . offerToReceiveVideo = false ;
2018-03-07 19:06:52 +01:00
this . rtcPeerConnection . onicecandidate = this . onIceCandidate . bind ( this ) ;
2018-09-21 23:25:03 +02:00
if ( this . local_audio_stream ) { //May a typecheck?
this . rtcPeerConnection . addStream ( this . local_audio_stream . stream ) ;
2018-09-23 15:24:07 +02:00
console . log ( "Adding stream (%o)!" , this . local_audio_stream . stream ) ;
2018-09-21 23:25:03 +02:00
}
2018-03-07 19:06:52 +01:00
this . rtcPeerConnection . createOffer ( this . onOfferCreated . bind ( this ) , ( ) = > {
console . error ( "Could not create ice offer!" ) ;
} , sdpConstraints ) ;
}
dropSession() {
if ( this . dataChannel ) this . dataChannel . close ( ) ;
if ( this . rtcPeerConnection ) this . rtcPeerConnection . close ( ) ;
//TODO here!
}
2018-08-08 19:32:12 +02:00
_ice_use_cache : boolean = true ;
_ice_cache : any [ ] = [ ] ;
2018-03-07 19:06:52 +01:00
handleControlPacket ( json ) {
2018-08-05 18:59:24 +02:00
if ( json [ "request" ] === "answer" ) {
console . log ( "Set remote sdp! (%o)" , json [ "msg" ] ) ;
2018-09-23 20:49:47 +02:00
this . rtcPeerConnection . setRemoteDescription ( new RTCSessionDescription ( json [ "msg" ] ) ) . catch ( error = > {
console . log ( "Failed to apply remote description: %o" , error ) ; //FIXME error handling!
} ) ;
2018-08-08 19:32:12 +02:00
this . _ice_use_cache = false ;
for ( let msg of this . _ice_cache ) {
2018-09-23 20:49:47 +02:00
this . rtcPeerConnection . addIceCandidate ( new RTCIceCandidate ( msg ) ) . catch ( error = > {
console . log ( "Failed to add remote cached ice candidate %s: %o" , msg , error ) ;
} ) ;
2018-08-08 19:32:12 +02:00
}
2018-03-07 19:06:52 +01:00
} else if ( json [ "request" ] === "ice" ) {
2018-08-08 19:32:12 +02:00
if ( ! this . _ice_use_cache ) {
console . log ( "Add remote ice! (%s | %o)" , json [ "msg" ] , json ) ;
2018-09-23 20:49:47 +02:00
this . rtcPeerConnection . addIceCandidate ( new RTCIceCandidate ( json [ "msg" ] ) ) . catch ( error = > {
console . log ( "Failed to add remote ice candidate %s: %o" , json [ "msg" ] , error ) ;
} ) ;
2018-08-08 19:32:12 +02:00
} else {
console . log ( "Cache remote ice! (%s | %o)" , json [ "msg" ] , json ) ;
this . _ice_cache . push ( json [ "msg" ] ) ;
}
} else if ( json [ "request" ] == "status" ) {
if ( json [ "state" ] == "failed" ) {
chat . serverChat ( ) . appendError ( "Failed to setup voice bridge ({}). Allow reconnect: {}" , json [ "reason" ] , json [ "allow_reconnect" ] ) ;
log . error ( LogCategory . NETWORKING , "Failed to setup voice bridge (%s). Allow reconnect: %s" , json [ "reason" ] , json [ "allow_reconnect" ] ) ;
if ( json [ "allow_reconnect" ] == true ) {
this . createSession ( ) ;
}
//TODO handle fail specially when its not allowed to reconnect
}
2018-03-07 19:06:52 +01:00
}
}
//Listeners
onIceCandidate ( event ) {
console . log ( "Got ice candidate! Event:" ) ;
console . log ( event ) ;
2018-09-23 20:49:47 +02:00
if ( event ) {
if ( event . candidate )
this . client . serverConnection . sendData ( JSON . stringify ( {
type : 'WebRTC' ,
request : "ice" ,
msg : event.candidate ,
} ) ) ;
else {
this . client . serverConnection . sendData ( JSON . stringify ( {
type : 'WebRTC' ,
request : "ice_finish"
} ) ) ;
}
2018-03-07 19:06:52 +01:00
}
}
onOfferCreated ( localSession ) {
console . log ( "Offer created and accepted" ) ;
2018-09-23 20:49:47 +02:00
this . rtcPeerConnection . setLocalDescription ( localSession ) . catch ( error = > {
console . log ( "Failed to apply local description: %o" , error ) ;
//FIXME error handling
} ) ;
2018-03-07 19:06:52 +01:00
2018-08-05 18:59:24 +02:00
console . log ( "Send offer: %o" , localSession ) ;
2018-08-08 19:32:12 +02:00
this . client . serverConnection . sendData ( JSON . stringify ( { type : 'WebRTC' , request : "create" , msg : localSession } ) ) ;
2018-03-07 19:06:52 +01:00
}
onDataChannelOpen ( channel ) {
2018-08-08 19:32:12 +02:00
console . log ( "Got new data channel! (%s)" , this . dataChannel . readyState ) ;
this . client . controlBar . updateVoice ( ) ;
2018-03-07 19:06:52 +01:00
}
onDataChannelMessage ( message ) {
2018-04-11 17:56:09 +02:00
if ( this . client . controlBar . muteOutput ) return ;
2018-03-07 19:06:52 +01:00
let bin = new Uint8Array ( message . data ) ;
let clientId = bin [ 2 ] << 8 | bin [ 3 ] ;
let packetId = bin [ 0 ] << 8 | bin [ 1 ] ;
let codec = bin [ 4 ] ;
//console.log("Client id " + clientId + " PacketID " + packetId + " Codec: " + codec);
let client = this . client . channelTree . findClient ( clientId ) ;
if ( ! client ) {
2018-05-09 11:50:05 +02:00
console . error ( "Having voice from unknown client? (ClientID: " + clientId + ")" ) ;
2018-03-07 19:06:52 +01:00
return ;
}
2018-04-11 17:56:09 +02:00
2018-08-08 19:32:12 +02:00
let codecPool = this . codec_pool [ codec ] ;
2018-04-11 17:56:09 +02:00
if ( ! codecPool ) {
2018-03-07 19:06:52 +01:00
console . error ( "Could not playback codec " + codec ) ;
return ;
}
2018-04-11 17:56:09 +02:00
2018-03-07 19:06:52 +01:00
let encodedData ;
if ( message . data . subarray )
encodedData = message . data . subarray ( 5 ) ;
else encodedData = new Uint8Array ( message . data , 5 ) ;
2018-03-07 20:14:36 +01:00
if ( encodedData . length == 0 ) {
client . getAudioController ( ) . stopAudio ( ) ;
2018-04-11 17:56:09 +02:00
codecPool . releaseCodec ( clientId ) ;
2018-03-07 20:14:36 +01:00
} else {
2018-04-18 20:12:10 +02:00
codecPool . ownCodec ( clientId )
. then ( decoder = > decoder . decodeSamples ( client . getAudioController ( ) . codecCache ( codec ) , encodedData ) )
. then ( buffer = > client . getAudioController ( ) . playBuffer ( buffer ) ) . catch ( error = > {
2018-08-08 19:32:12 +02:00
console . error ( "Could not playback client's (" + clientId + ") audio (" + error + ")" ) ;
2018-08-12 20:19:44 +02:00
if ( error instanceof Error )
console . error ( error . stack ) ;
2018-08-08 19:32:12 +02:00
} ) ;
2018-03-07 20:14:36 +01:00
}
2018-03-07 19:06:52 +01:00
}
private handleVoiceData ( data : AudioBuffer , head : boolean ) {
2018-04-11 17:56:09 +02:00
if ( ! this . voiceRecorder ) return ;
2018-04-18 16:25:10 +02:00
if ( ! this . client . connected ) return false ;
if ( this . client . controlBar . muteInput ) return ;
2018-04-11 17:56:09 +02:00
2018-03-07 19:06:52 +01:00
if ( head ) {
this . chunkVPacketId = 0 ;
this . client . getClient ( ) . speaking = true ;
}
2018-04-18 20:12:10 +02:00
//TODO Use channel codec!
2018-08-08 19:32:12 +02:00
this . codec_pool [ 4 ] . ownCodec ( this . client . getClientId ( ) )
2018-06-19 20:31:05 +02:00
. then ( encoder = > encoder . encodeSamples ( this . client . getClient ( ) . getAudioController ( ) . codecCache ( 4 ) , data ) ) ;
2018-03-07 19:06:52 +01:00
}
private handleVoiceEnded() {
2018-09-22 15:14:39 +02:00
if ( this . client && this . client . getClient ( ) )
this . client . getClient ( ) . speaking = false ;
2018-04-11 17:56:09 +02:00
if ( ! this . voiceRecorder ) return ;
2018-06-19 20:31:05 +02:00
if ( ! this . client . connected ) return ;
2018-09-22 15:14:39 +02:00
console . log ( "Local voice ended" ) ;
2018-04-11 17:56:09 +02:00
2018-05-05 14:58:30 +02:00
if ( this . dataChannel )
2018-09-21 23:25:03 +02:00
this . sendVoicePacket ( new Uint8Array ( 0 ) , 5 ) ; //TODO Use channel codec!
2018-03-07 19:06:52 +01:00
}
2018-09-22 15:14:39 +02:00
private handleVoiceStarted() {
console . log ( "Local voice started" ) ;
if ( this . client && this . client . getClient ( ) )
this . client . getClient ( ) . speaking = true ;
}
2018-03-07 19:06:52 +01:00
}