diff --git a/ts/CI.preload.ts b/ts/CI.preload.ts index d05bac3a75..0ddb8c9f65 100644 --- a/ts/CI.preload.ts +++ b/ts/CI.preload.ts @@ -56,6 +56,8 @@ export type CIType = { resetReleaseNoteAndMegaphoneFetcher(): void; forceUnprocessed: boolean; setMediaPermissions(): Promise; + maybeUpdateMaxAudioLevel: (level: number) => void; + getAndResetMaxAudioLevel: () => number | undefined; }; export type GetCIOptionsType = Readonly<{ @@ -251,6 +253,21 @@ export function getCI({ await window.IPC.setMediaPermissions(true); } + let maxAudioLevel: number | undefined; + + function maybeUpdateMaxAudioLevel(level: number) { + if (maxAudioLevel === undefined || maxAudioLevel < level) { + maxAudioLevel = level; + } + } + + // Tracker for maximum received audio level in a 1:1 call + function getAndResetMaxAudioLevel(): number | undefined { + const level = maxAudioLevel; + maxAudioLevel = undefined; + return level; + } + return { deviceName, getConversationId, @@ -273,5 +290,7 @@ export function getCI({ resetReleaseNoteAndMegaphoneFetcher, forceUnprocessed, setMediaPermissions, + maybeUpdateMaxAudioLevel, + getAndResetMaxAudioLevel, }; } diff --git a/ts/services/calling.preload.ts b/ts/services/calling.preload.ts index 15cd081748..7e44553d5d 100644 --- a/ts/services/calling.preload.ts +++ b/ts/services/calling.preload.ts @@ -3666,6 +3666,7 @@ export class CallingClass { // eslint-disable-next-line no-param-reassign call.handleAudioLevels = () => { + window.SignalCI?.maybeUpdateMaxAudioLevel(call.remoteAudioLevel); reduxInterface.directCallAudioLevelsChange({ conversationId, localAudioLevel: call.outgoingAudioLevel, diff --git a/ts/test-mock/calling/callMessages_test.docker.node.ts b/ts/test-mock/calling/callMessages_test.docker.node.ts index 363f910176..98fa3c5ed2 100644 --- a/ts/test-mock/calling/callMessages_test.docker.node.ts +++ b/ts/test-mock/calling/callMessages_test.docker.node.ts @@ -225,6 +225,13 @@ describe('callMessages', function callMessages(this: Mocha.Suite) { '.module-ongoing-call__direct-call-speaking-indicator > .CallingAudioIndicator--with-content' ) ).toBeVisible({ timeout: 15000 }); + + // Wait a second to let the audio play + await new Promise(f => setTimeout(f, 2000)); + + expect( + await window2.evaluate('window.SignalCI?.getAndResetMaxAudioLevel()') + ).toBeGreaterThanOrEqual(0.25); } finally { await bootstrap2.screenshotWindow(window2, 'callee'); // hang up after we detect audio (or fail to) @@ -246,4 +253,99 @@ describe('callMessages', function callMessages(this: Mocha.Suite) { await bootstrap2.screenshotWindow(window2, 'callee'); } }); + + it('mute consistency regression', async () => { + const theRaven = join(FIXTURES, 'the_raven.wav'); + + const window1 = await app1.getWindow(); + + const window2 = await app2.getWindow(); + + // First call: Neither muted, window2 ends call + + await startAudioCallWith(window1, bootstrap2.phone.device.aci); + + // Only wait for 3 seconds to make sure that this succeeded properly rather + // than timing out after ~10 seconds and using a direct connection + await window2 + .locator('.IncomingCallBar__button--accept-audio') + .click({ timeout: 3000 }); + + try { + await setInputAndOutput(window1, 'input_source_a', 'output_sink_a'); + + await setInputAndOutput(window2, 'input_source_b', 'output_sink_b'); + } finally { + // hang up + await window2.locator('.CallControls__JoinLeaveButton--hangup').click(); + + await awaitNoCall(window1); + await awaitNoCall(window2); + } + + // Second call + await startAudioCallWith(window1, bootstrap2.phone.device.aci); + + // window1 mutes after placing call but before window 2 answers + await window1.getByLabel('Mute mic').click(); + + // Only wait for 3 seconds to make sure that this succeeded properly rather + // than timing out after ~10 seconds and using a direct connection + await window2 + .locator('.IncomingCallBar__button--accept-audio') + .click({ timeout: 3000 }); + + // Wait a few hundred ms for initial comfort noise to subside + await new Promise(f => setTimeout(f, 600)); + // We haven't played any audio into the virtual mic yet, so it's safe to + // ignore anything this early / to assume it's comfort noise + await window2.evaluate('window.SignalCI?.getAndResetMaxAudioLevel()'); + + try { + await setInputAndOutput(window1, 'input_source_a', 'output_sink_a'); + + await setInputAndOutput(window2, 'input_source_b', 'output_sink_b'); + + execFile( + VIRTUAL_AUDIO, + [ + '--play', + '--input-source', + 'input_source_a', + '--output-sink', + 'output_sink_a', + '--input-file', + theRaven, + ], + (error, stdout, stderr) => { + if (error) { + throw error; + } + debug(stdout); + debug(stderr); + } + ); + + // Wait a second + await new Promise(f => setTimeout(f, 2000)); + + // Make sure we got no audio + expect( + await window2.evaluate('window.SignalCI?.getAndResetMaxAudioLevel()') + ).toBeCloseTo(0); + } finally { + await window2.locator('.CallControls__JoinLeaveButton--hangup').click(); + + await execFilePromise(VIRTUAL_AUDIO, [ + '--stop', + '--input-source', + 'input_source_a', + '--output-sink', + 'output_source_a', + ]); + + await awaitNoCall(window1); + await awaitNoCall(window2); + } + }); });