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 authored and GitHub committed 2026-08-14 16:17:31 -07:00
1 parent 65bc52d18e
commit 85d6811761
33 files changed
+1480 -572

No files matched your search

+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);