fix: Release the microphone to the browser when a call ends (#2685)

The microphone is connected when a call needs it and released the moment
that
call ends, so the browser stops reporting it as in use at hangup rather
than
`disconnect_seconds` later.

Existing configurations are migrated automatically by the visual editor.

 - Closes: #2681 

BREAKING CHANGE: `live.microphone.disconnect_seconds` is removed. The
microphone is released when a call ends, so there is no idle countdown
to
configure. Use `live.microphone.always_connected` to hold it open
instead.

BREAKING CHANGE: The `microphone_connect` and `microphone_disconnect`
actions
are removed. The card owns the microphone lifecycle; `microphone_mute`
and
`microphone_unmute` remain.

BREAKING CHANGE: `call` is removed from `live.microphone.auto_mute`,
whose
default is now `[]`. The microphone is muted when a call ends regardless
of
this option.

BREAKING CHANGE: `microphone_unmute` has no effect outside a call.
Nothing
carries the audio at any other time, so the request is ignored rather
than
opening the microphone.
This commit is contained in:
Dermot Duffy
2026-08-14 16:17:31 -07:00
committed by GitHub
parent 65bc52d18e
commit 85d6811761
33 changed files with 1480 additions and 572 deletions
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneConnectAction } from '../../../../src/card-controller/actions/actions/microphone-connect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_connect action', async () => {
const api = createCardAPI();
const action = new MicrophoneConnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
});
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneDisconnectAction } from '../../../../src/card-controller/actions/actions/microphone-disconnect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_disconnect action', async () => {
const api = createCardAPI();
const action = new MicrophoneDisconnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_disconnect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().disconnect).toHaveBeenCalled();
});
@@ -20,8 +20,6 @@ import { InternalCallbackAction } from '../../../src/card-controller/actions/act
import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle';
import { MicrophoneConnectAction } from '../../../src/card-controller/actions/actions/microphone-connect';
import { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect';
import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute';
import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute';
import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info';
@@ -146,14 +144,6 @@ describe('ActionFactory', () => {
MediaPlayerAction,
],
[{ advanced_camera_card_action: 'menu_toggle' as const }, MenuToggleAction],
[
{ advanced_camera_card_action: 'microphone_connect' as const },
MicrophoneConnectAction,
],
[
{ advanced_camera_card_action: 'microphone_disconnect' as const },
MicrophoneDisconnectAction,
],
[
{ advanced_camera_card_action: 'microphone_mute' as const },
MicrophoneMuteAction,
+356 -38
View File
@@ -527,7 +527,7 @@ describe('start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
expect(await new CallManager(api).start()).toBe(true);
@@ -1426,7 +1426,7 @@ describe('answer', () => {
microphoneConnected: false,
config: inboundConfig,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue();
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
@@ -1442,9 +1442,9 @@ describe('answer', () => {
microphoneConnected: false,
config: inboundConfig,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1459,7 +1459,7 @@ describe('answer', () => {
// microphone permission prompt to be dealt with.
expect(getRingtone().stop).toHaveBeenCalled();
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(true);
});
@@ -1540,22 +1540,26 @@ describe('answer', () => {
});
});
describe('microphone usage', () => {
it('should mark the microphone in use for the duration of the call', async () => {
describe('microphone transmission', () => {
it('should transmit for the duration of the call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
manager.end();
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not mark the microphone in use when the start is aborted', async () => {
it('should stop transmitting when the call fails to start', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
@@ -1564,10 +1568,18 @@ describe('microphone usage', () => {
expect(await new CallManager(api).start()).toBe(false);
expect(api.getMicrophoneManager().startUsing).not.toHaveBeenCalled();
// Transmission is granted ahead of the microphone connect, so the failed
// start must withdraw it again.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenNthCalledWith(
1,
true,
);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should keep the microphone in use when a call supersedes another', async () => {
it('should keep transmitting when one call replaces another', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
@@ -1586,26 +1598,331 @@ describe('microphone usage', () => {
expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
});
it('should mark the microphone unused on uninitialization', async () => {
it('should stop transmitting when uninitialized during a call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
manager.uninitialize();
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not mark the microphone unused when uninitializing without a call', () => {
it('should stop transmitting when uninitialized without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).uninitialize();
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled();
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(false);
});
it('should leave transmission alone when an unanswered inbound call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should stop transmitting when uninitialized while a call connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start();
manager.uninitialize();
// The grant must not outlive the manager while the connect is still
// pending.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await startPromise).toBe(false);
});
it('should let a later call stop transmitting after an abandoned call is uninitialized', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
// A start from the first lifecycle never gets its microphone.
const stalledStart = manager.start();
manager.uninitialize();
manager.initialize();
// A call in the new lifecycle must end normally: the stalled request from
// the old lifecycle no longer holds transmission.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(true);
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await stalledStart).toBe(false);
});
it('should transmit for a call that starts while another call is ending', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// The superseding start is suspended on the microphone while the answered
// call ends and reports transmission inactive.
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await second).toBe(true);
// The installed session restated its need so the user can be heard on unmute.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should not transmit for an inbound call that rings after another call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await manager.start({ inbound: true })).toBe(true);
// A ring transmits nothing, so it must not restate a need for
// transmission.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should stop transmitting when a call ends and the next cannot get the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((_, reject) => {
rejectConnect = reject;
}),
);
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
rejectConnect(new Error());
expect(await second).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should abort a call when the microphone does not stay connected', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const start = manager.start();
// The microphone manager releases a stream it connected while nothing was
// transmitting, and reports that the microphone is not connected.
resolveConnect(false);
expect(await start).toBe(false);
expect(manager.isActive()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not transmit while an inbound call rings', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should allow transmission before connecting the microphone on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(true);
// If transmission is not yet active when a connect completes, the
// microphone manager immediately releases the new stream -- so the answer
// must activate transmission before it connects. The ringing start touches
// neither method, so index 0 is each method's first and only invocation,
// made by the answer.
const transmitOrder = vi.mocked(api.getMicrophoneManager().setTransmissionActive)
.mock.invocationCallOrder[0];
const connectOrder = vi.mocked(api.getMicrophoneManager().connect).mock
.invocationCallOrder[0];
expect(transmitOrder).toBeLessThan(connectOrder);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should stop transmitting when the microphone connect fails on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(manager.getCall()?.answered).toBe(false);
});
it('should keep transmitting when a failed start leaves an earlier call in progress', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
// The second start fails its microphone connect, but the first call is
// still answered and holds the transmission grant.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
expect(manager.getCall()?.cameraID).toBe('camera.office');
});
it('should stop transmitting when the call ends while answering connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const answerPromise = manager.answer();
manager.end();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
});
@@ -1839,9 +2156,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1850,7 +2167,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start();
manager.uninitialize();
resolveConnect();
resolveConnect(true);
expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -1862,9 +2179,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1874,7 +2191,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start();
manager.uninitialize();
manager.initialize();
resolveConnect();
resolveConnect(true);
expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -1896,9 +2213,9 @@ describe('state changes during in-flight start', () => {
},
]),
});
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
@@ -1908,17 +2225,18 @@ describe('state changes during in-flight start', () => {
// Both requests read the (absent) session before either can install one.
const first = manager.start();
const second = manager.start({ cameraID: 'camera.garage' });
resolveConnect();
resolveConnect(true);
expect(await first).toBe(true);
expect(await second).toBe(true);
// The second request must end the first request's session rather than
// overwrite it, leaving exactly one live session and no stranded microphone
// marking.
// overwrite it, leaving exactly one live session with transmission still
// granted.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2);
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
});
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
@@ -1928,7 +2246,7 @@ describe('state changes during in-flight start', () => {
});
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((_, reject) => {
new Promise<boolean>((_, reject) => {
rejectConnect = reject;
}),
);
@@ -1957,7 +2275,7 @@ describe('state changes during in-flight answer', () => {
api: CardController,
): Promise<{
manager: CallManager;
resolveConnect: () => void;
resolveConnect: (connected: boolean) => void;
rejectConnect: (reason: unknown) => void;
}> => {
const manager = new CallManager(api);
@@ -1965,10 +2283,10 @@ describe('state changes during in-flight answer', () => {
expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let resolveConnect: () => void = () => {};
let resolveConnect: (connected: boolean) => void = () => {};
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve, reject) => {
new Promise<boolean>((resolve, reject) => {
resolveConnect = resolve;
rejectConnect = reject;
}),
@@ -1985,7 +2303,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer();
manager.uninitialize();
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false);
@@ -2015,7 +2333,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer();
expect(manager.end()).toBe(true);
resolveConnect();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false);
+331 -211
View File
@@ -28,7 +28,6 @@ const medialessNavigatorMock: Navigator = {
describe('MicrophoneManager', () => {
beforeEach(() => {
vi.stubGlobal('navigator', navigatorMock);
vi.useRealTimers();
});
afterEach(() => {
@@ -44,30 +43,100 @@ describe('MicrophoneManager', () => {
return stream;
};
// Streams from `createMockStream` carry exactly one track.
const getTrack = (stream: MediaStream): MediaStreamTrack => stream.getTracks()[0];
const createDeferredStream = (): {
promise: Promise<MediaStream>;
resolve: (stream: MediaStream) => void;
reject: (error: Error) => void;
} => {
let resolveStream: ((stream: MediaStream) => void) | null = null;
let rejectStream: ((error: Error) => void) | null = null;
const promise = new Promise<MediaStream>((resolve, reject) => {
resolveStream = resolve;
rejectStream = reject;
});
return {
promise,
resolve: (stream: MediaStream) => resolveStream?.(stream),
reject: (error: Error) => rejectStream?.(error),
};
};
it('should be muted on creation', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should be undefined without creation', () => {
it('should have no stream before connecting', () => {
const manager = new MicrophoneManager(createCardAPI());
expect(manager.getStream()).toBeUndefined();
expect(manager.getStream()).toBeNull();
});
it('should connect', async () => {
it('should connect while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should release a stream that connects without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(await manager.connect()).toBe(false);
expect(manager.isConnected()).toBeFalsy();
expect(manager.isForbidden()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
});
it('should hold a muted stream without transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
manager.mute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should be unsupported without browser support', () => {
@@ -104,24 +173,74 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should mute and unmute', async () => {
it('should mute and unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
manager.setTransmissionActive(true);
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.mute();
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3);
manager.mute();
expect(manager.isMuted()).toBeTruthy();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(4);
// Unmuting again reuses the held stream rather than re-requesting the
// device.
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1);
});
it('should not unmute without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(navigatorMock.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(manager.isConnected()).toBeFalsy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
});
it('should not unmute without active transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should not unmute when microphone forbidden', async () => {
@@ -129,14 +248,15 @@ describe('MicrophoneManager', () => {
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
manager.setTransmissionActive(true);
await expect(manager.connect()).rejects.toThrow(Error);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
await manager.unmute();
expect(manager.isForbidden()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
it('should not unmute when not supported', async () => {
@@ -150,13 +270,14 @@ describe('MicrophoneManager', () => {
expect(manager.isMuted()).toBeTruthy();
});
it('should connect on unmute', async () => {
it('should connect on unmute while transmission is active', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
manager.setTransmissionActive(true);
expect(manager.isConnected()).toBeFalsy();
await manager.unmute();
@@ -167,236 +288,234 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should disconnect', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.disconnect();
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should automatically disconnect', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should not automatically disconnect when always connected', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
describe('should stay connected while in use', () => {
const disconnectSeconds = 10;
const createManagerWithDisconnectSeconds = (): MicrophoneManager => {
describe('should follow transmission state', () => {
it('should release the stream and reset mute when transmission ends', async () => {
const api = createCardAPI();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
await manager.connect();
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
// The next transmission must not start with a hot microphone: the desired mute
// was reset when the previous transmission ended.
const newStream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(newStream);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should keep the stream muted when transmission ends and always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
always_connected: true,
},
},
}),
);
return new MicrophoneManager(api);
};
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
it('should not automatically disconnect while a user is registered', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
manager.setTransmissionActive(true);
await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect when a user is registered before connection', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
manager.startUsing();
await manager.connect();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect on mute or unmute while in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
await manager.unmute();
manager.mute();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
});
it('should restart the countdown in full when use stops', async () => {
vi.useFakeTimers();
it('should ignore the end of a transmission that never started', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000 - 1);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(manager.isConnected()).toBeFalsy();
});
manager.setTransmissionActive(false);
it('should become idle on a single stop no matter how often use was started', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.startUsing();
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
});
it('should restart the countdown when stopped without having been in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.stopUsing();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeTruthy();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
it('should not reconnect after an explicit disconnect while in use', async () => {
vi.useFakeTimers();
it('should ignore a transmission report that changes nothing', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.disconnect();
manager.setTransmissionActive(true);
manager.setTransmissionActive(true);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
});
it('should not disconnect after being explicitly disconnected and reconnected', async () => {
vi.useFakeTimers();
describe('should guard in-flight connections', () => {
it('should release a stream whose connect completes after a mute', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const connectPromise = manager.connect();
manager.mute();
deferred.resolve(stream);
await connectPromise;
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
await manager.connect();
it('should release a stream whose connect completes after transmission ends', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.disconnect();
await manager.connect();
manager.setTransmissionActive(true);
const connectPromise = manager.connect();
manager.setTransmissionActive(false);
deferred.resolve(stream);
await connectPromise;
// The countdown from the first connection must not survive the disconnect
// and cut the second one short.
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeTruthy();
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
});
it('should not install a stream whose connect completes after the held stream is released', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const held = createMockStream();
const late = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(held)
.mockReturnValueOnce(deferred.promise);
manager.setTransmissionActive(true);
await manager.connect();
const connectPromise = manager.connect();
// Releasing the held stream also invalidates the connect still in
// flight.
manager.setTransmissionActive(false);
deferred.resolve(late);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(held).stop).toHaveBeenCalled();
expect(getTrack(late).stop).toHaveBeenCalled();
});
it('should keep only the newest of overlapping connects', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.resolve(streamA);
deferredB.resolve(streamB);
expect(await connectA).toBe(false);
expect(await connectB).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
it('should not mark forbidden on a stale connect rejection', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.reject(new Error());
deferredB.resolve(streamB);
await expect(connectA).rejects.toThrow(Error);
await connectB;
expect(manager.isForbidden()).toBeFalsy();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
});
it('should stop the replaced stream when connecting over an existing stream', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(streamA)
.mockResolvedValueOnce(streamB);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.getStream()).toBe(streamA);
await manager.connect();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
});
describe('should require initialization', async () => {
@@ -457,7 +576,7 @@ describe('MicrophoneManager', () => {
manager.initialize();
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
microphone: { connected: false, muted: true, forbidden: false, stream: undefined },
microphone: { connected: false, muted: true, forbidden: false, stream: null },
});
});
@@ -469,6 +588,7 @@ describe('MicrophoneManager', () => {
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
manager.setTransmissionActive(true);
await manager.connect();
let expectedState: MicrophoneState = {
@@ -515,11 +635,11 @@ describe('MicrophoneManager', () => {
}),
);
manager.disconnect();
manager.setTransmissionActive(false);
expectedState = {
forbidden: false,
stream: undefined,
stream: null,
connected: false,
muted: true,
};