diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 9e12db20f4..55bb3f2278 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -145,6 +145,14 @@ describe('Call', function() { client.client.mediaHandler = new MockMediaHandler; client.client.getMediaHandler = () => client.client.mediaHandler; client.httpBackend.when("GET", "/voip/turnServer").respond(200, {}); + client.client.getRoom = () => { + return { + getMember: () => { + return {}; + }, + }; + }; + call = new MatrixCall({ client: client.client, roomId: '!foo:bar', @@ -175,6 +183,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); call.peerConn.addIceCandidate = jest.fn(); @@ -192,6 +201,7 @@ describe('Call', function() { ], }; }, + getSender: () => "@test:foo", }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); @@ -209,6 +219,7 @@ describe('Call', function() { ], }; }, + getSender: () => "@test:foo", }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); @@ -236,6 +247,7 @@ describe('Call', function() { ], }; }, + getSender: () => "@test:foo", }); call.onRemoteIceCandidatesReceived({ @@ -252,6 +264,7 @@ describe('Call', function() { ], }; }, + getSender: () => "@test:foo", }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(0); @@ -267,6 +280,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); @@ -291,6 +305,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); const identChangedCallback = jest.fn(); @@ -308,6 +323,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); expect(identChangedCallback).toHaveBeenCalled(); @@ -347,6 +363,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); call.pushRemoteFeed(new MockMediaStream("remote_stream")); @@ -376,6 +393,7 @@ describe('Call', function() { }, }; }, + getSender: () => "@test:foo", }); call.setScreensharingEnabledWithoutMetadataSupport = jest.fn(); diff --git a/src/client.ts b/src/client.ts index 0885554872..3ce67168ce 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1322,6 +1322,7 @@ export class MatrixClient extends EventEmitter { public async createGroupCall( roomId: string, type: GroupCallType, + isPtt: boolean, intent: GroupCallIntent, dataChannelsEnabled?: boolean, dataChannelOptions?: IGroupCallDataChannelOptions, @@ -1340,6 +1341,7 @@ export class MatrixClient extends EventEmitter { this, room, type, + isPtt, intent, undefined, dataChannelsEnabled, diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index 45ccb6099c..32f6fa2955 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -59,6 +59,12 @@ export class GroupCallError extends Error { } } +export class OtherUserSpeakingError extends Error { + constructor() { + super("Cannot unmute: another user is speaking"); + } +} + export interface IGroupCallDataChannelOptions { ordered: boolean; maxPacketLifeTime: number; @@ -112,6 +118,7 @@ export class GroupCall extends EventEmitter { public activeSpeakerInterval = 1000; public retryCallInterval = 5000; public participantTimeout = 1000 * 15; + public pttMaxTransmitTime = 1000 * 20; public state = GroupCallState.LocalCallFeedUninitialized; public activeSpeaker?: string; // userId @@ -129,11 +136,13 @@ export class GroupCall extends EventEmitter { private retryCallLoopTimeout?: number; private retryCallCounts: Map = new Map(); private reEmitter: ReEmitter; + private transmitTimer: number | null = null; constructor( private client: MatrixClient, public room: Room, public type: GroupCallType, + public isPtt: boolean, public intent: GroupCallIntent, groupCallId?: string, private dataChannelsEnabled?: boolean, @@ -160,6 +169,7 @@ export class GroupCall extends EventEmitter { { "m.intent": this.intent, "m.type": this.type, + "io.element.ptt": this.isPtt, // TODO: Specify datachannels "dataChannelsEnabled": this.dataChannelsEnabled, "dataChannelOptions": this.dataChannelOptions, @@ -208,6 +218,11 @@ export class GroupCall extends EventEmitter { throw error; } + // start muted on ptt calls + if (this.isPtt) { + setTracksEnabled(stream.getAudioTracks(), false); + } + const userId = this.client.getUserId(); const callFeed = new CallFeed({ @@ -216,7 +231,7 @@ export class GroupCall extends EventEmitter { userId, stream, purpose: SDPStreamMetadataPurpose.Usermedia, - audioMuted: stream.getAudioTracks().length === 0, + audioMuted: stream.getAudioTracks().length === 0 || this.isPtt, videoMuted: stream.getVideoTracks().length === 0, }); @@ -318,10 +333,20 @@ export class GroupCall extends EventEmitter { this.retryCallCounts.clear(); clearTimeout(this.retryCallLoopTimeout); + if (this.transmitTimer !== null) { + clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + this.client.removeListener("Call.incoming", this.onIncomingCall); } public leave() { + if (this.transmitTimer !== null) { + clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + this.dispose(); this.setState(GroupCallState.LocalCallFeedUninitialized); } @@ -329,6 +354,11 @@ export class GroupCall extends EventEmitter { public async terminate(emitStateEvent = true) { this.dispose(); + if (this.transmitTimer !== null) { + clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + this.participants = []; this.client.removeListener( "RoomState.members", @@ -382,6 +412,24 @@ export class GroupCall extends EventEmitter { return false; } + // set a timer for the maximum transmit time on PTT calls + if (this.isPtt) { + // if anoher user is currently unmuted, we can't unmute + if (!muted && this.userMediaFeeds.some(f => !f.isAudioMuted())) { + throw new OtherUserSpeakingError(); + } + + // Set or clear the max transmit timer + if (!muted && this.isMicrophoneMuted()) { + this.transmitTimer = setTimeout(() => { + this.setMicrophoneMuted(true); + }, this.pttMaxTransmitTime); + } else if (muted && !this.isMicrophoneMuted()) { + clearTimeout(this.transmitTimer); + this.transmitTimer = null; + } + } + if (this.localCallFeed) { logger.log(`groupCall ${this.groupCallId} setMicrophoneMuted stream ${ this.localCallFeed.stream.id} muted ${muted}`); diff --git a/src/webrtc/groupCallEventHandler.ts b/src/webrtc/groupCallEventHandler.ts index 99e726f1f1..476d0acfab 100644 --- a/src/webrtc/groupCallEventHandler.ts +++ b/src/webrtc/groupCallEventHandler.ts @@ -93,6 +93,8 @@ export class GroupCallEventHandler { return; } + const isPtt = Boolean(content["io.element.ptt"]); + let dataChannelOptions: IGroupCallDataChannelOptions | undefined; if (content?.dataChannelsEnabled && content?.dataChannelOptions) { @@ -105,6 +107,7 @@ export class GroupCallEventHandler { this.client, room, callType, + isPtt, callIntent, groupCallId, content?.dataChannelsEnabled,