Calling Declarative Transcription Notice (#190)
* Add CallingDeclarative transcription * Add unit tests * Change files * Revise changelog message * Address code review comments * Fix build break * Fix comment broken by merge * Update api doc
This commit is contained in:
Родитель
6168a8007a
Коммит
e476866319
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "prerelease",
|
||||
"comment": "Add transcription notice capability to Calling Declarative",
|
||||
"packageName": "@azure/acs-calling-declarative",
|
||||
"email": "allenhwang@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
|
@ -36,6 +36,7 @@ export interface Call {
|
|||
remoteParticipantsEnded: Map<string, RemoteParticipant>;
|
||||
startTime: Date;
|
||||
state: CallState;
|
||||
transcription: TranscriptionCallFeature;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
@ -110,6 +111,11 @@ export interface RemoteVideoStream {
|
|||
videoStreamRendererView: VideoStreamRendererView | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface TranscriptionCallFeature {
|
||||
isTranscriptionActive: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface VideoStreamRendererView {
|
||||
isMirrored: boolean;
|
||||
|
|
|
@ -2,13 +2,17 @@
|
|||
import {
|
||||
Call,
|
||||
CallAgent,
|
||||
CallFeatureFactoryType,
|
||||
CollectionUpdatedEvent,
|
||||
GroupChatCallLocator,
|
||||
GroupLocator,
|
||||
IncomingCallEvent,
|
||||
JoinCallOptions,
|
||||
MeetingLocator,
|
||||
StartCallOptions
|
||||
RecordingCallFeature,
|
||||
StartCallOptions,
|
||||
TranscriptionCallFeature,
|
||||
TransferCallFeature
|
||||
} from '@azure/communication-calling';
|
||||
import { CommunicationUserIdentifier, PhoneNumberIdentifier, UnknownIdentifier } from '@azure/communication-common';
|
||||
import EventEmitter from 'events';
|
||||
|
@ -22,12 +26,29 @@ import {
|
|||
MockCall,
|
||||
MockIncomingCall,
|
||||
mockoutObjectFreeze,
|
||||
MockRecordingCallFeatureImpl,
|
||||
MockTranscriptionCallFeatureImpl,
|
||||
MockTransferCallFeatureImpl,
|
||||
waitWithBreakCondition
|
||||
} from './TestUtils';
|
||||
|
||||
mockoutObjectFreeze();
|
||||
|
||||
jest.mock('@azure/communication-calling');
|
||||
jest.mock('@azure/communication-calling', () => {
|
||||
return {
|
||||
Features: {
|
||||
get Recording(): CallFeatureFactoryType<RecordingCallFeature> {
|
||||
return MockRecordingCallFeatureImpl;
|
||||
},
|
||||
get Transfer(): CallFeatureFactoryType<TransferCallFeature> {
|
||||
return MockTransferCallFeatureImpl;
|
||||
},
|
||||
get Transcription(): CallFeatureFactoryType<TranscriptionCallFeature> {
|
||||
return MockTranscriptionCallFeatureImpl;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const mockRemoteParticipantId = 'a';
|
||||
const mockCallId = 'b';
|
||||
|
|
|
@ -95,6 +95,9 @@ class ProxyCallAgent implements ProxyHandler<CallAgent> {
|
|||
|
||||
private addCall = (call: Call): void => {
|
||||
this._callSubscribers.get(call)?.unsubscribe();
|
||||
|
||||
// For API extentions we need to have the call in the state when we are subscribing as we may want to update the
|
||||
// state during the subscription process in the subscriber so we add the call to state before subscribing.
|
||||
this._context.setCall(convertSdkCallToDeclarativeCall(call));
|
||||
this._callSubscribers.set(call, new CallSubscriber(call, this._context, this._internalContext));
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
CallClient,
|
||||
CallFeatureFactoryType,
|
||||
CreateViewOptions,
|
||||
Features,
|
||||
LocalVideoStream,
|
||||
RecordingCallFeature,
|
||||
RemoteVideoStream,
|
||||
|
@ -28,8 +29,7 @@ import {
|
|||
MockRecordingCallFeatureImpl,
|
||||
MockRemoteParticipant,
|
||||
MockTranscriptionCallFeatureImpl,
|
||||
MockTransferCallFeatureImpl,
|
||||
MOCK_RECORDING_NAME
|
||||
MockTransferCallFeatureImpl
|
||||
} from './TestUtils';
|
||||
|
||||
mockoutObjectFreeze();
|
||||
|
@ -824,7 +824,9 @@ describe('declarative call client', () => {
|
|||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
mockCall.api = createMockApiFeatures(true, new Map<string, any>());
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Recording, addMockEmitter({ name: 'Default', isRecordingActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
|
@ -834,13 +836,31 @@ describe('declarative call client', () => {
|
|||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect if call already has transcription active', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Transcription, addMockEmitter({ name: 'Default', isTranscriptionActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect recording changes in call', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<string, any>();
|
||||
mockCall.api = createMockApiFeatures(true, featureCache);
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Recording, addMockEmitter({ name: 'Default', isRecordingActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
|
@ -849,7 +869,7 @@ describe('declarative call client', () => {
|
|||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
|
||||
const recording = featureCache.get(MOCK_RECORDING_NAME);
|
||||
const recording = featureCache.get(Features.Recording);
|
||||
recording.isRecordingActive = false;
|
||||
recording.emitter.emit('isRecordingActiveChanged');
|
||||
|
||||
|
@ -860,19 +880,49 @@ describe('declarative call client', () => {
|
|||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(false);
|
||||
});
|
||||
|
||||
test('should detect transcription changes in call', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Transcription, addMockEmitter({ name: 'Default', isTranscriptionActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive).toBe(true);
|
||||
|
||||
const transcription = featureCache.get(Features.Transcription);
|
||||
transcription.isTranscriptionActive = false;
|
||||
transcription.emitter.emit('isTranscriptionActiveChanged');
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive === false
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive).toBe(false);
|
||||
});
|
||||
|
||||
test('should unsubscribe to recording changes when call ended', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<string, any>();
|
||||
mockCall.api = createMockApiFeatures(true, featureCache);
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Recording, addMockEmitter({ name: 'Default', isRecordingActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true
|
||||
);
|
||||
|
||||
expect(() => testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive === true);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.recording.isRecordingActive).toBe(true);
|
||||
|
||||
testData.mockCallAgent.calls = [];
|
||||
|
@ -883,7 +933,35 @@ describe('declarative call client', () => {
|
|||
|
||||
await waitWithBreakCondition(() => testData.declarativeCallClient.state.calls.size === 0);
|
||||
|
||||
const recording = featureCache.get(MOCK_RECORDING_NAME);
|
||||
const recording = featureCache.get(Features.Recording);
|
||||
expect(recording.emitter.eventNames().length).toBe(0);
|
||||
});
|
||||
|
||||
test('should unsubscribe to transcription changes when call ended', async () => {
|
||||
const testData = {} as TestData;
|
||||
createClientAndAgentMocks(testData);
|
||||
createDeclarativeClient(testData);
|
||||
const mockCall = createMockCall(mockCallId);
|
||||
const featureCache = new Map<any, any>();
|
||||
featureCache.set(Features.Transcription, addMockEmitter({ name: 'Default', isTranscriptionActive: true }));
|
||||
mockCall.api = createMockApiFeatures(featureCache);
|
||||
await createMockCallAndEmitCallsUpdated(testData, undefined, mockCall);
|
||||
|
||||
await waitWithBreakCondition(
|
||||
() => testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive === true
|
||||
);
|
||||
|
||||
expect(testData.declarativeCallClient.state.calls.get(mockCallId)?.transcription.isTranscriptionActive).toBe(true);
|
||||
|
||||
testData.mockCallAgent.calls = [];
|
||||
testData.mockCallAgent.emit('callsUpdated', {
|
||||
added: [],
|
||||
removed: [testData.mockCall]
|
||||
});
|
||||
|
||||
await waitWithBreakCondition(() => testData.declarativeCallClient.state.calls.size === 0);
|
||||
|
||||
const transcription = featureCache.get(Features.Transcription);
|
||||
expect(transcription.emitter.eventNames().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,6 +19,16 @@ import {
|
|||
UnknownIdentifierKind
|
||||
} from '@azure/communication-common';
|
||||
|
||||
/**
|
||||
* State only version of {@Link @azure/communication-calling#TranscriptionCallFeature}.
|
||||
*/
|
||||
export interface TranscriptionCallFeature {
|
||||
/**
|
||||
* Proxy of {@Link @azure/communication-calling#TranscriptionCallFeature.isTranscriptionActive}.
|
||||
*/
|
||||
isTranscriptionActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State only version of {@Link @azure/communication-calling#RecordingCallFeature}.
|
||||
*/
|
||||
|
@ -172,6 +182,10 @@ export interface Call {
|
|||
* {@Link Converter.getRemoteParticipantKey} to {@Link @azure/communication-calling#RemoteParticipant}
|
||||
*/
|
||||
remoteParticipantsEnded: Map<string, RemoteParticipant>;
|
||||
/**
|
||||
* Proxy of {@Link @azure/communication-calling#TranscriptionCallFeature}.
|
||||
*/
|
||||
transcription: TranscriptionCallFeature;
|
||||
/**
|
||||
* Proxy of {@Link @azure/communication-calling#RecordingCallFeature}.
|
||||
*/
|
||||
|
|
|
@ -78,6 +78,7 @@ export class CallContext {
|
|||
existingCall.isScreenSharingOn = call.isScreenSharingOn;
|
||||
existingCall.localVideoStreams = call.localVideoStreams;
|
||||
existingCall.remoteParticipants = call.remoteParticipants;
|
||||
existingCall.transcription.isTranscriptionActive = call.transcription.isTranscriptionActive;
|
||||
existingCall.recording.isRecordingActive = call.recording.isRecordingActive;
|
||||
// We don't update the startTime and endTime if we are updating an existing active call
|
||||
} else {
|
||||
|
@ -219,6 +220,17 @@ export class CallContext {
|
|||
);
|
||||
}
|
||||
|
||||
public setCallTranscriptionActive(callId: string, isTranscriptionActive: boolean): void {
|
||||
this.setState(
|
||||
produce(this._state, (draft: CallClientState) => {
|
||||
const call = draft.calls.get(callId);
|
||||
if (call) {
|
||||
call.transcription.isTranscriptionActive = isTranscriptionActive;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public setLocalVideoStreamRendererView(callId: string, view: VideoStreamRendererView | undefined): void {
|
||||
this.setState(
|
||||
produce(this._state, (draft: CallClientState) => {
|
||||
|
|
|
@ -12,6 +12,7 @@ import { InternalCallContext } from './InternalCallContext';
|
|||
import { ParticipantSubscriber } from './ParticipantSubscriber';
|
||||
import { RecordingSubscriber } from './RecordingSubscriber';
|
||||
import { stopRenderVideo } from './StreamUtils';
|
||||
import { TranscriptionSubscriber } from './TranscriptionSubscriber';
|
||||
|
||||
/**
|
||||
* Keeps track of the listeners assigned to a particular call because when we get an event from SDK, it doesn't tell us
|
||||
|
@ -25,6 +26,7 @@ export class CallSubscriber {
|
|||
private _internalContext: InternalCallContext;
|
||||
private _participantSubscribers: Map<string, ParticipantSubscriber>;
|
||||
private _recordingSubscriber: RecordingSubscriber;
|
||||
private _transcriptionSubscriber: TranscriptionSubscriber;
|
||||
|
||||
constructor(call: Call, context: CallContext, internalContext: InternalCallContext) {
|
||||
this._call = call;
|
||||
|
@ -39,6 +41,12 @@ export class CallSubscriber {
|
|||
this._call.api(Features.Recording)
|
||||
);
|
||||
|
||||
this._transcriptionSubscriber = new TranscriptionSubscriber(
|
||||
this._callIdRef,
|
||||
this._context,
|
||||
this._call.api(Features.Transcription)
|
||||
);
|
||||
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
|
@ -85,6 +93,7 @@ export class CallSubscriber {
|
|||
stopRenderVideo(this._context, this._internalContext, this._callIdRef.callId, localVideoStreams[0]);
|
||||
}
|
||||
|
||||
this._transcriptionSubscriber.unsubscribe();
|
||||
this._recordingSubscriber.unsubscribe();
|
||||
};
|
||||
|
||||
|
|
|
@ -111,6 +111,7 @@ export function convertSdkCallToDeclarativeCall(call: SdkCall): DeclarativeCall
|
|||
remoteParticipants: declarativeRemoteParticipants,
|
||||
remoteParticipantsEnded: new Map<string, DeclarativeRemoteParticipant>(),
|
||||
recording: { isRecordingActive: false },
|
||||
transcription: { isTranscriptionActive: false },
|
||||
startTime: new Date(),
|
||||
endTime: undefined
|
||||
};
|
||||
|
|
|
@ -73,6 +73,7 @@ function createMockCall(mockCallId: string): Call {
|
|||
remoteParticipants: new Map<string, RemoteParticipant>(),
|
||||
remoteParticipantsEnded: new Map<string, RemoteParticipant>(),
|
||||
recording: { isRecordingActive: false },
|
||||
transcription: { isTranscriptionActive: false },
|
||||
startTime: new Date(),
|
||||
endTime: undefined
|
||||
};
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
CallAgent,
|
||||
CallApiFeature,
|
||||
CallFeatureFactoryType,
|
||||
Features,
|
||||
IncomingCall,
|
||||
LocalVideoStream,
|
||||
PropertyChangedEvent,
|
||||
|
@ -117,7 +116,7 @@ export function createMockCall(mockCallId: string): MockCall {
|
|||
id: mockCallId,
|
||||
remoteParticipants: [] as ReadonlyArray<RemoteParticipant>,
|
||||
localVideoStreams: [] as ReadonlyArray<LocalVideoStream>,
|
||||
api: createMockApiFeatures(false, new Map<string, any>())
|
||||
api: createMockApiFeatures(new Map())
|
||||
} as MockCall;
|
||||
return addMockEmitter(mockCall);
|
||||
}
|
||||
|
@ -140,32 +139,34 @@ export function createMockRemoteVideoStream(mockIsAvailable: boolean): MockRemot
|
|||
return addMockEmitter(mockRemoteVideoStream);
|
||||
}
|
||||
|
||||
export const MOCK_RECORDING_NAME = 'Recording';
|
||||
|
||||
/**
|
||||
* Create a mockApiFeatures function see {@Link @azure/communication-calling#Call.api}. If cache is passed in, will try
|
||||
* to return existing cached TFeatures else create new TFeature, cache it, and then return it.
|
||||
* Creates a function equivalent to Call.api. The api() generated will use the passed in cache to return the feature
|
||||
* objects as defined in the cache. For any undefined feature not in cache, it will return a generic object. Containing
|
||||
* properties of all features. Note that this generic object is instanciated every call whereas the cache objects are
|
||||
* reused on repeated calls.
|
||||
*
|
||||
* @param isRecording
|
||||
* @param cache
|
||||
* @returns
|
||||
*/
|
||||
export function createMockApiFeatures(
|
||||
isRecording: boolean,
|
||||
cache: Map<string, any>
|
||||
cache: Map<CallFeatureFactoryType<any>, CallApiFeature>
|
||||
): <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>) => FeatureT {
|
||||
return <FeatureT extends CallApiFeature>(cls: CallFeatureFactoryType<FeatureT>): FeatureT => {
|
||||
if (typeof cls === typeof Features.Recording) {
|
||||
if (cache.has(MOCK_RECORDING_NAME)) {
|
||||
return cache.get(MOCK_RECORDING_NAME);
|
||||
}
|
||||
const recording = addMockEmitter({
|
||||
isRecordingActive: isRecording
|
||||
});
|
||||
cache.set(MOCK_RECORDING_NAME, recording);
|
||||
return recording;
|
||||
const feature = cache.get(cls);
|
||||
if (feature) {
|
||||
return feature as FeatureT;
|
||||
} else {
|
||||
throw new Error('Not implemented');
|
||||
// Default one if none provided
|
||||
const generic = addMockEmitter({
|
||||
name: 'Default',
|
||||
isRecordingActive: false,
|
||||
isTranscriptionActive: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
transfer(target: TransferToParticipant, transferOptions?: TransferToParticipantOptions): Transfer {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
});
|
||||
return generic;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// © Microsoft Corporation. All rights reserved.
|
||||
|
||||
import { TranscriptionCallFeature } from '@azure/communication-calling';
|
||||
import { CallContext } from './CallContext';
|
||||
import { CallIdRef } from './CallIdRef';
|
||||
|
||||
export class TranscriptionSubscriber {
|
||||
private _callIdRef: CallIdRef;
|
||||
private _context: CallContext;
|
||||
private _transcription: TranscriptionCallFeature;
|
||||
|
||||
constructor(callIdRef: CallIdRef, context: CallContext, transcription: TranscriptionCallFeature) {
|
||||
this._callIdRef = callIdRef;
|
||||
this._context = context;
|
||||
this._transcription = transcription;
|
||||
|
||||
// If transcription as already started when we joined the call, make sure it is reflected in state as there may not
|
||||
// be an event for it.
|
||||
if (this._transcription.isTranscriptionActive) {
|
||||
this._context.setCallTranscriptionActive(this._callIdRef.callId, this._transcription.isTranscriptionActive);
|
||||
}
|
||||
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
private subscribe = (): void => {
|
||||
this._transcription.on('isTranscriptionActiveChanged', this.isTranscriptionActiveChanged);
|
||||
};
|
||||
|
||||
public unsubscribe = (): void => {
|
||||
this._transcription.off('isTranscriptionActiveChanged', this.isTranscriptionActiveChanged);
|
||||
};
|
||||
|
||||
private isTranscriptionActiveChanged = (): void => {
|
||||
this._context.setCallTranscriptionActive(this._callIdRef.callId, this._transcription.isTranscriptionActive);
|
||||
};
|
||||
}
|
|
@ -10,5 +10,6 @@ export type {
|
|||
RemoteVideoStream,
|
||||
RemoteParticipant,
|
||||
VideoStreamRendererView,
|
||||
RecordingCallFeature
|
||||
RecordingCallFeature,
|
||||
TranscriptionCallFeature
|
||||
} from './CallClientState';
|
||||
|
|
Загрузка…
Ссылка в новой задаче