diff --git a/packages/voice_engine_v2/src/core/reducer.test.ts b/packages/voice_engine_v2/src/core/reducer.test.ts index 7bb94a13d..b32f3640f 100644 --- a/packages/voice_engine_v2/src/core/reducer.test.ts +++ b/packages/voice_engine_v2/src/core/reducer.test.ts @@ -847,6 +847,45 @@ describe('transitionVoiceEngineV2', () => { expect(selectVoiceEngineV2SourceLifecycle(snapshot, 'unknown')).toBeNull(); }); + it('sourceLifecycle.removed drops the lifecycle slot for that source only', () => { + const snapshot = applyEvents(initialSnapshot(), [ + { + type: 'sourceLifecycle.transitioned', + sourceId: 'source-removed', + kind: 'failed', + since: 11n, + attempts: 8, + fault: 'captureDeviceLost', + atMs: 11, + }, + { + type: 'sourceLifecycle.transitioned', + sourceId: 'source-kept', + kind: 'active', + since: 12n, + attempts: 0, + fault: null, + atMs: 12, + }, + ]); + + const transition = transitionVoiceEngineV2(snapshot, {type: 'sourceLifecycle.removed', sourceId: 'source-removed'}); + + expect(transition.commands).toEqual([]); + expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-removed')).toBeNull(); + expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-kept')?.kind).toBe('active'); + expect([...selectVoiceEngineV2FailedSourceIds(transition.snapshot)]).toEqual([]); + }); + + it('sourceLifecycle.removed for an unknown source leaves the snapshot untouched', () => { + const snapshot = initialSnapshot(); + + const transition = transitionVoiceEngineV2(snapshot, {type: 'sourceLifecycle.removed', sourceId: 'source-absent'}); + + expect(transition.commands).toEqual([]); + expect(transition.snapshot).toBe(snapshot); + }); + it('selectVoiceEngineV2FailedSourceIds returns only failed lifecycle ids', () => { const snapshot = applyEvents(initialSnapshot(), [ { diff --git a/packages/voice_engine_v2/src/core/reducers/_dispatch.ts b/packages/voice_engine_v2/src/core/reducers/_dispatch.ts index 4e3f33bc8..ca6e8b053 100644 --- a/packages/voice_engine_v2/src/core/reducers/_dispatch.ts +++ b/packages/voice_engine_v2/src/core/reducers/_dispatch.ts @@ -276,6 +276,7 @@ export function dispatchObservabilityEvent( assert.equal(typeof event.type, 'string', 'dispatchObservabilityEvent event.type must be a string'); switch (event.type) { case 'sourceLifecycle.transitioned': + case 'sourceLifecycle.removed': return transitionSourceLifecycles(snapshot, event); default: return null; diff --git a/packages/voice_engine_v2/src/core/reducers/sourceLifecycles.ts b/packages/voice_engine_v2/src/core/reducers/sourceLifecycles.ts index 02a8a189a..b85337565 100644 --- a/packages/voice_engine_v2/src/core/reducers/sourceLifecycles.ts +++ b/packages/voice_engine_v2/src/core/reducers/sourceLifecycles.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import assert from 'node:assert/strict'; -import type {VoiceEngineV2SourceLifecycleTransitionedEvent} from '../../protocol/events'; +import type { + VoiceEngineV2SourceLifecycleRemovedEvent, + VoiceEngineV2SourceLifecycleTransitionedEvent, +} from '../../protocol/events'; import type {VoiceEngineV2DiagnosticEntry} from '../../protocol/types'; import type {SourceFault, SourceLifecycleState} from '../../source_isolation/SourceLifecycleState'; import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state'; @@ -9,7 +12,9 @@ import {allocateOperation, appendDiagnostic, queueCommand} from './_helpers'; const SOURCE_LIFECYCLE_DIAGNOSTICS_CODE = 'sourceFailed'; -type VoiceEngineV2SourceLifecyclesEvent = VoiceEngineV2SourceLifecycleTransitionedEvent; +type VoiceEngineV2SourceLifecyclesEvent = + | VoiceEngineV2SourceLifecycleTransitionedEvent + | VoiceEngineV2SourceLifecycleRemovedEvent; function assertNeverLifecycleKind(kind: never): never { assert.fail(`unhandled source lifecycle kind: ${JSON.stringify(kind)}`); @@ -43,6 +48,12 @@ export function transitionSourceLifecycles( assert.ok(event != null, 'transitionSourceLifecycles event must not be null'); assert.equal(typeof event.sourceId, 'string', 'sourceLifecycle.sourceId must be a string'); assert.ok(event.sourceId.length > 0, 'sourceLifecycle.sourceId must not be empty'); + if (event.type === 'sourceLifecycle.removed') { + if (!Object.hasOwn(snapshot.sourceLifecycles, event.sourceId)) return {snapshot, commands: []}; + const sourceLifecycles = {...snapshot.sourceLifecycles}; + delete sourceLifecycles[event.sourceId]; + return {snapshot: {...snapshot, sourceLifecycles}, commands: []}; + } const nextState = rebuildSourceLifecycleState(event); const base: VoiceEngineV2Snapshot = { ...snapshot, diff --git a/packages/voice_engine_v2/src/index.ts b/packages/voice_engine_v2/src/index.ts index c32f744f2..3402808c9 100644 --- a/packages/voice_engine_v2/src/index.ts +++ b/packages/voice_engine_v2/src/index.ts @@ -212,6 +212,7 @@ export type { VoiceEngineV2ScreenOptions, VoiceEngineV2ScreenPacing, VoiceEngineV2SendStats, + VoiceEngineV2SourceLifecycleRemovedEvent, VoiceEngineV2SourceLifecycleTransitionedEvent, VoiceEngineV2SourceLifecycleTransitionKind, VoiceEngineV2Stats, diff --git a/packages/voice_engine_v2/src/protocol/events.ts b/packages/voice_engine_v2/src/protocol/events.ts index ef78f4445..02a53f18f 100644 --- a/packages/voice_engine_v2/src/protocol/events.ts +++ b/packages/voice_engine_v2/src/protocol/events.ts @@ -15,6 +15,11 @@ export interface VoiceEngineV2SourceLifecycleTransitionedEvent { atMs: number; } +export interface VoiceEngineV2SourceLifecycleRemovedEvent { + type: 'sourceLifecycle.removed'; + sourceId: string; +} + import type { VoiceEngineV2AudioControlsPatch, VoiceEngineV2CameraEncodingOptions, @@ -264,4 +269,5 @@ export type VoiceEngineV2Event = | {type: 'inboundVideo.trackUnsubscribed'; trackSid: string} | {type: 'inboundVideo.frameReceived'; frame: VoiceEngineV2InboundVideoFrame} | {type: 'inboundVideo.frameStats'; stats: VoiceEngineV2InboundVideoFrameStats} - | VoiceEngineV2SourceLifecycleTransitionedEvent; + | VoiceEngineV2SourceLifecycleTransitionedEvent + | VoiceEngineV2SourceLifecycleRemovedEvent; diff --git a/packages/voice_engine_v2/src/protocol/index.ts b/packages/voice_engine_v2/src/protocol/index.ts index c444febf3..aefafb57f 100644 --- a/packages/voice_engine_v2/src/protocol/index.ts +++ b/packages/voice_engine_v2/src/protocol/index.ts @@ -9,6 +9,7 @@ export type {VoiceEngineV2Command, VoiceEngineV2CommandType} from './commands'; export {getVoiceEngineV2CommandResourceKey, getVoiceEngineV2CommandTypeResourceKey} from './commands'; export type { VoiceEngineV2Event, + VoiceEngineV2SourceLifecycleRemovedEvent, VoiceEngineV2SourceLifecycleTransitionedEvent, VoiceEngineV2SourceLifecycleTransitionKind, } from './events';