From f501d34179e09c7a30cf66697718b5bad26563e0 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 13 Oct 2026 22:02:40 +0200 Subject: [PATCH] fix(voice): allow self-mute while push-to-talk is on (#3451) --- .../app/components/layout/UserArea.tsx | 8 +- .../components/layout/UserAreaState.test.ts | 82 +++++++ .../app/components/layout/UserAreaState.ts | 7 +- .../app/keybindings/KeybindManager.ts | 2 +- .../utils/PushToTalkModeReconcile.test.ts | 10 +- .../utils/PushToTalkModeReconcile.ts | 5 +- .../commands/VoiceStateCommands.test.ts | 197 +++++++++++++++ .../devtools/commands/VoiceStateCommands.tsx | 31 ++- .../items/VoiceParticipantMenuData.tsx | 8 +- .../items/VoiceParticipantMenuItems.tsx | 8 +- .../voice/components/VoiceControlBar.tsx | 1 + .../VoiceControlBarStateMachine.test.ts | 69 ++++++ .../components/VoiceControlBarStateMachine.ts | 9 +- .../DirectCallLobbyBottomSheet.tsx | 4 +- .../bottomsheets/VoiceLobbyBottomSheet.tsx | 5 +- .../voice/engine/MediaEngineFacade.ts | 30 +-- .../engine/VoiceChannelConnector.test.ts | 90 +++++++ .../voice/engine/VoiceChannelConnector.tsx | 6 +- .../engine/VoiceLocalSpeakingGate.test.ts | 33 +++ ...neV2AppAudioPreferencesSyncBinding.test.ts | 63 +++++ ...eEngineV2AppAudioPreferencesSyncBinding.ts | 6 +- .../VoiceEngineV2AppMediaExecutionAdapter.tsx | 21 +- ...VoiceEngineV2AppPushToTalkSelfMute.test.ts | 230 ++++++++++++++++++ .../v2/VoiceEngineV2AppSelectors.test.ts | 93 +++++++ .../engine/v2/VoiceEngineV2AppSelectors.ts | 8 +- .../fixtures/event_logs/ptt_self_mute.json | 76 ++++++ .../src/core/localMediaFixtures.test.ts | 2 + 27 files changed, 1025 insertions(+), 79 deletions(-) create mode 100644 fluxer_app/src/features/app/components/layout/UserAreaState.test.ts create mode 100644 fluxer_app/src/features/devtools/commands/VoiceStateCommands.test.ts create mode 100644 fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.test.ts create mode 100644 fluxer_app/src/features/voice/engine/VoiceChannelConnector.test.ts create mode 100644 fluxer_app/src/features/voice/engine/VoiceLocalSpeakingGate.test.ts create mode 100644 fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.test.ts create mode 100644 fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPushToTalkSelfMute.test.ts create mode 100644 fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.test.ts create mode 100644 packages/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json diff --git a/fluxer_app/src/features/app/components/layout/UserArea.tsx b/fluxer_app/src/features/app/components/layout/UserArea.tsx index 3e4fcd290..a55790fa3 100644 --- a/fluxer_app/src/features/app/components/layout/UserArea.tsx +++ b/fluxer_app/src/features/app/components/layout/UserArea.tsx @@ -206,6 +206,7 @@ const UserAreaInner = observer( }, [hasVoiceConnection]); const wrapperClassName = styles.userAreaInnerWrapper; const isPushToTalkEffective = Keybind.isPushToTalkEffective(); + const isSelfMuted = isMuted || muteReason === 'self'; const microphoneState = selectUserAreaMicrophoneState({ effectiveAudioMuted: isMuted, effectiveAudioDeafened: isDeafened, @@ -214,7 +215,6 @@ const UserAreaInner = observer( isPermissionMuted, muteReason, isPushToTalkEffective, - isPushToTalkHeld: Keybind.pushToTalkHeld, isPushToMuteEffective: Keybind.isPushToMuteEffective(), isPushToMuteHeld: Keybind.pushToMuteHeld, }); @@ -224,7 +224,8 @@ const UserAreaInner = observer( if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true); if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR); if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR); - if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR); + if (isPushToTalkEffective && !isSelfMuted) + return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR); if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR); return i18n._(MUTE_MICROPHONE_DESCRIPTOR); })(); @@ -232,7 +233,8 @@ const UserAreaInner = observer( if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true); if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR); if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR); - if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR); + if (isPushToTalkEffective && !isSelfMuted) + return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR); if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR); return i18n._(MUTE_MICROPHONE_DESCRIPTOR); })(); diff --git a/fluxer_app/src/features/app/components/layout/UserAreaState.test.ts b/fluxer_app/src/features/app/components/layout/UserAreaState.test.ts new file mode 100644 index 000000000..2cfd36c63 --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/UserAreaState.test.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + selectUserAreaMicrophoneState, + type UserAreaMicrophoneSignals, +} from '@app/features/app/components/layout/UserAreaState'; +import {describe, expect, it} from 'vitest'; + +function signals(overrides: Partial): UserAreaMicrophoneSignals { + return { + effectiveAudioMuted: false, + effectiveAudioDeafened: false, + isGuildMuted: false, + isGuildDeafened: false, + isPermissionMuted: false, + muteReason: null, + isPushToTalkEffective: false, + isPushToMuteEffective: false, + isPushToMuteHeld: false, + ...overrides, + }; +} + +describe('selectUserAreaMicrophoneState', () => { + it('shows the idle push to talk gate as unmuted without locking the toggle', () => { + expect( + selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'voice_push_to_talk'})), + ).toEqual({effectiveMuted: false, muteToggleLocked: false}); + }); + + it('shows a self-muted push to talk user as muted', () => { + expect( + selectUserAreaMicrophoneState( + signals({isPushToTalkEffective: true, effectiveAudioMuted: true, muteReason: 'self'}), + ), + ).toEqual({effectiveMuted: true, muteToggleLocked: false}); + }); + + it('shows a push to talk user muted by the failure latch as muted', () => { + expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'self'}))).toEqual({ + effectiveMuted: true, + muteToggleLocked: false, + }); + }); + + it('lets a self-deafened push to talk user click the toggle', () => { + expect( + selectUserAreaMicrophoneState( + signals({isPushToTalkEffective: true, effectiveAudioMuted: true, effectiveAudioDeafened: true}), + ), + ).toEqual({effectiveMuted: true, muteToggleLocked: false}); + }); + + it('locks the toggle for server and permission mutes in push to talk', () => { + for (const overrides of [ + {isGuildMuted: true, muteReason: 'guild' as const}, + {isGuildDeafened: true}, + {isPermissionMuted: true, muteReason: 'permission' as const}, + ]) { + expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, ...overrides}))).toEqual({ + effectiveMuted: true, + muteToggleLocked: true, + }); + } + }); + + it('keeps voice activity and push to mute behaviour unchanged', () => { + expect(selectUserAreaMicrophoneState(signals({}))).toEqual({effectiveMuted: false, muteToggleLocked: false}); + expect(selectUserAreaMicrophoneState(signals({effectiveAudioMuted: true, muteReason: 'self'}))).toEqual({ + effectiveMuted: true, + muteToggleLocked: false, + }); + expect(selectUserAreaMicrophoneState(signals({isPushToMuteEffective: true, isPushToMuteHeld: true}))).toEqual({ + effectiveMuted: true, + muteToggleLocked: false, + }); + expect(selectUserAreaMicrophoneState(signals({isGuildMuted: true}))).toEqual({ + effectiveMuted: true, + muteToggleLocked: true, + }); + }); +}); diff --git a/fluxer_app/src/features/app/components/layout/UserAreaState.ts b/fluxer_app/src/features/app/components/layout/UserAreaState.ts index 5077bf410..328ea59cc 100644 --- a/fluxer_app/src/features/app/components/layout/UserAreaState.ts +++ b/fluxer_app/src/features/app/components/layout/UserAreaState.ts @@ -10,7 +10,6 @@ export interface UserAreaMicrophoneSignals { isPermissionMuted: boolean; muteReason: UserAreaMuteReason; isPushToTalkEffective: boolean; - isPushToTalkHeld: boolean; isPushToMuteEffective: boolean; isPushToMuteHeld: boolean; } @@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals if (hardMuted) { return { effectiveMuted: true, - muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective, + muteToggleLocked, }; } if (signals.isPushToTalkEffective) { return { - effectiveMuted: !signals.isPushToTalkHeld, - muteToggleLocked: true, + effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self', + muteToggleLocked, }; } return { diff --git a/fluxer_app/src/features/app/keybindings/KeybindManager.ts b/fluxer_app/src/features/app/keybindings/KeybindManager.ts index 96690556e..d67afe0b2 100644 --- a/fluxer_app/src/features/app/keybindings/KeybindManager.ts +++ b/fluxer_app/src/features/app/keybindings/KeybindManager.ts @@ -521,7 +521,7 @@ class KeybindManager { }), ); } - this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options))); + this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange())); this.disposers.push( reaction( () => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}), diff --git a/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.test.ts b/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.test.ts index 5cc7ca30d..de9aca269 100644 --- a/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.test.ts +++ b/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.test.ts @@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => { dispose(); }); - it('flips push-to-talk only on settled states and preserves self-mute when it does', () => { + it('flips push-to-talk only on settled states and reconciles once per flip', () => { const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules; const reconcile = vi.fn(); const dispose = reactToPushToTalkModeChanges(reconcile); @@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => { emit(status); expect(Keybind.isPushToTalkEffective()).toBe(effective); expect(reconcile).toHaveBeenCalledTimes(1); - expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true}); + expect(reconcile).toHaveBeenLastCalledWith(); }; expectFlip(portalStatus('declined'), false); expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]); @@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => { expectFlip(portalStatus('bound'), true); reconcile.mockClear(); Keybind.setTransmitMode('voice_activity'); - expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false}); + expect(Keybind.isPushToTalkEffective()).toBe(false); + expect(reconcile).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenLastCalledWith(); Keybind.setTransmitMode('voice_push_to_talk'); + expect(Keybind.isPushToTalkEffective()).toBe(true); + expect(reconcile).toHaveBeenCalledTimes(2); dispose(); }); }); diff --git a/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.ts b/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.ts index 88ce7db11..c068909a5 100644 --- a/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.ts +++ b/fluxer_app/src/features/app/keybindings/utils/PushToTalkModeReconcile.ts @@ -3,14 +3,13 @@ import Keybind from '@app/features/input/state/InputKeybind'; import {compareStructural, reaction} from 'mobx'; -export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void { +export function reactToPushToTalkModeChanges(onChange: () => void): () => void { return reaction( () => ({ - transmitMode: Keybind.transmitMode, pushToTalk: Keybind.isPushToTalkEffective(), pushToMute: Keybind.isPushToMuteEffective(), }), - (current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}), + () => onChange(), {equals: compareStructural}, ); } diff --git a/fluxer_app/src/features/devtools/commands/VoiceStateCommands.test.ts b/fluxer_app/src/features/devtools/commands/VoiceStateCommands.test.ts new file mode 100644 index 000000000..dcea31287 --- /dev/null +++ b/fluxer_app/src/features/devtools/commands/VoiceStateCommands.test.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const playSound = vi.fn(); + +const localVoiceState = { + selfMute: false, + selfDeaf: false, + getSelfMute: () => localVoiceState.selfMute, + getSelfDeaf: () => localVoiceState.selfDeaf, + updateSelfMute: (muted: boolean) => { + localVoiceState.selfMute = muted; + }, + toggleSelfMute: () => { + if (localVoiceState.selfDeaf) { + localVoiceState.selfDeaf = false; + localVoiceState.selfMute = false; + return; + } + localVoiceState.selfMute = !localVoiceState.selfMute; + }, +}; + +const permissionMuted = {active: false}; +const microphoneFailureLatch = {active: false}; + +vi.mock('@app/features/input/state/InputKeybind', () => ({ + default: {isPushToTalkEffective: () => true, pushToTalkHeld: true}, +})); +const updateVoiceStateExplicit = vi.fn(); +const gatewayVoiceStates: Record< + string, + { + guild_id: string; + channel_id: string; + self_mute: boolean; + self_deaf: boolean; + self_video: boolean; + self_stream: boolean; + } +> = {}; + +vi.mock('@app/features/gateway/transport/GatewaySessionPool', () => ({ + default: {foregroundConnection: {socket: {updateVoiceStateExplicit}}}, +})); +vi.mock('@app/features/ui/commands/SoundCommands', () => ({ + playSound: (...args: Array) => playSound(...args), +})); +vi.mock('@app/features/permissions/system/commands/MacPermissionsModalCommands', () => ({ + handleMediaPermissionBlocked: () => undefined, +})); +vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({ + default: { + getMicrophonePermissionState: () => 'granted', + isMicrophoneGranted: () => true, + isMicrophoneExplicitlyDenied: () => false, + }, +})); +vi.mock('@app/features/permissions/system/utils/NativePermissions', () => ({ + ensureNativePermission: async () => 'granted', +})); +vi.mock('@app/features/ui/utils/NativeUtils', () => ({isDesktop: () => false})); +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({ + default: { + room: null, + guildId: '1', + channelId: '2', + connectionId: 'local', + getVoiceStateByConnectionId: (connectionId: string) => gatewayVoiceStates[connectionId] ?? null, + isMicrophoneFailureLatched: () => microphoneFailureLatch.active, + clearMicrophoneFailureLatch: () => { + microphoneFailureLatch.active = false; + }, + getCurrentUserVoiceState: () => null, + syncLocalVoiceStateWithServer: () => undefined, + }, +})); +vi.mock('@app/features/voice/state/LocalVoiceState', () => ({default: localVoiceState})); +vi.mock('@app/features/voice/utils/VoicePermissionUtils', () => ({ + isVoicePermissionMuteActive: () => permissionMuted.active, +})); + +const {bulkMuteConnections, getCurrentSelfMute, getSelfMuteForConnection, toggleSelfMute, toggleSelfMuteForConnection} = + await import('@app/features/devtools/commands/VoiceStateCommands'); +const {SoundType} = await import('@app/features/notification/utils/SoundUtils'); + +describe('toggleSelfMute with push to talk enabled', () => { + beforeEach(() => { + localVoiceState.selfMute = false; + localVoiceState.selfDeaf = false; + permissionMuted.active = false; + microphoneFailureLatch.active = false; + playSound.mockClear(); + }); + + it('mutes and unmutes', async () => { + await toggleSelfMute(null); + expect(localVoiceState.selfMute).toBe(true); + expect(playSound).toHaveBeenLastCalledWith(SoundType.Mute); + await toggleSelfMute(null); + expect(localVoiceState.selfMute).toBe(false); + expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute); + }); + + it('undeafens like voice activity mode', async () => { + localVoiceState.selfMute = true; + localVoiceState.selfDeaf = true; + await toggleSelfMute(null); + expect(localVoiceState.selfDeaf).toBe(false); + expect(localVoiceState.selfMute).toBe(false); + }); + + it('keeps a permission-muted user muted', async () => { + localVoiceState.selfMute = true; + permissionMuted.active = true; + await toggleSelfMute(null); + expect(localVoiceState.selfMute).toBe(true); + }); +}); + +describe('device-scoped self-mute with push to talk enabled', () => { + beforeEach(() => { + localVoiceState.selfMute = false; + localVoiceState.selfDeaf = false; + permissionMuted.active = false; + microphoneFailureLatch.active = false; + playSound.mockClear(); + updateVoiceStateExplicit.mockClear(); + const idle = { + guild_id: '1', + channel_id: '2', + self_mute: true, + self_deaf: false, + self_video: false, + self_stream: false, + }; + gatewayVoiceStates.local = {...idle}; + gatewayVoiceStates.other = {...idle, self_mute: false}; + }); + + it('reads the local device from local self-mute instead of the push-to-talk gateway value', () => { + expect(getSelfMuteForConnection('local')).toBe(false); + localVoiceState.selfMute = true; + gatewayVoiceStates.local.self_mute = false; + expect(getSelfMuteForConnection('local')).toBe(true); + expect(getSelfMuteForConnection('other')).toBe(false); + }); + + it('toggles the local device through local self-mute without a direct gateway write', async () => { + await toggleSelfMuteForConnection('local'); + expect(localVoiceState.selfMute).toBe(true); + expect(playSound).toHaveBeenLastCalledWith(SoundType.Mute); + await toggleSelfMuteForConnection('local'); + expect(localVoiceState.selfMute).toBe(false); + expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute); + expect(updateVoiceStateExplicit).not.toHaveBeenCalled(); + }); + + it('toggles another device through the gateway without touching local self-mute', async () => { + await toggleSelfMuteForConnection('other'); + expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1); + expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith( + expect.objectContaining({connection_id: 'other', self_mute: true}), + ); + expect(localVoiceState.selfMute).toBe(false); + }); + + it('reports a latched microphone failure as self-muted and unmutes it on toggle', async () => { + microphoneFailureLatch.active = true; + expect(getCurrentSelfMute()).toBe(true); + expect(getSelfMuteForConnection('local')).toBe(true); + await toggleSelfMuteForConnection('local'); + expect(microphoneFailureLatch.active).toBe(false); + expect(getCurrentSelfMute()).toBe(false); + expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute); + }); + + it('bulk mutes the local device through local self-mute and other devices through the gateway', async () => { + await bulkMuteConnections(['local', 'other'], true); + expect(localVoiceState.selfMute).toBe(true); + expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1); + expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith( + expect.objectContaining({connection_id: 'other', self_mute: true}), + ); + updateVoiceStateExplicit.mockClear(); + gatewayVoiceStates.other.self_mute = true; + microphoneFailureLatch.active = true; + await bulkMuteConnections(['local', 'other'], false); + expect(localVoiceState.selfMute).toBe(false); + expect(microphoneFailureLatch.active).toBe(false); + expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1); + expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith( + expect.objectContaining({connection_id: 'other', self_mute: false}), + ); + }); +}); diff --git a/fluxer_app/src/features/devtools/commands/VoiceStateCommands.tsx b/fluxer_app/src/features/devtools/commands/VoiceStateCommands.tsx index b9f878990..fa0ea841d 100644 --- a/fluxer_app/src/features/devtools/commands/VoiceStateCommands.tsx +++ b/fluxer_app/src/features/devtools/commands/VoiceStateCommands.tsx @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool'; -import Keybind from '@app/features/input/state/InputKeybind'; import {SoundType} from '@app/features/notification/utils/SoundUtils'; import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands'; import MediaPermission from '@app/features/permissions/system/state/MediaPermission'; @@ -119,14 +118,10 @@ const requestMicrophoneDirectly = async (): Promise => { }; export async function toggleSelfMute(_guildId: string | null = null): Promise { - if (Keybind.isPushToTalkEffective()) { - logger.debug('Ignoring self-mute toggle: push-to-talk controls the microphone'); - return; - } const room = MediaEngine.room; const connectedChannelId = MediaEngine.channelId; const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched(); - const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched; + const currentMute = getCurrentSelfMute(); const currentDeaf = LocalVoiceState.getSelfDeaf(); const willUndeafen = currentDeaf; const willUnmute = currentMute; @@ -238,7 +233,20 @@ const updateConnectionsProperty = async ( } }; +export function getCurrentSelfMute(): boolean { + return LocalVoiceState.getSelfMute() || MediaEngine.isMicrophoneFailureLatched(); +} + +export function getSelfMuteForConnection(connectionId: string): boolean { + if (connectionId === MediaEngine.connectionId) return getCurrentSelfMute(); + return MediaEngine.getVoiceStateByConnectionId(connectionId)?.self_mute ?? false; +} + export async function toggleSelfMuteForConnection(connectionId: string): Promise { + if (connectionId === MediaEngine.connectionId) { + await toggleSelfMute(null); + return; + } const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId); if (!voiceState) return; const target = !voiceState.self_mute; @@ -265,7 +273,16 @@ export async function turnOffStreamForConnection(connectionId: string): Promise< } export async function bulkMuteConnections(connectionIds: Array, mute: boolean = true): Promise { - await updateConnectionsProperty(connectionIds, 'self_mute', mute); + const currentConnectionId = MediaEngine.connectionId; + if (currentConnectionId && connectionIds.includes(currentConnectionId)) { + if (!mute) MediaEngine.clearMicrophoneFailureLatch(); + LocalVoiceState.updateSelfMute(mute); + } + await updateConnectionsProperty( + connectionIds.filter((connectionId) => connectionId !== currentConnectionId), + 'self_mute', + mute, + ); } export async function bulkDeafenConnections(connectionIds: Array, deafen: boolean = true): Promise { diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx index 0a6055cd8..7bb201a5f 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx @@ -667,7 +667,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt const useConnectionSpecificControls = connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId); if (useConnectionSpecificControls) { - const isSelfMuted = connectionVoiceState?.self_mute ?? false; + const isSelfMuted = VoiceStateCommands.getSelfMuteForConnection(connectionId); const isSelfDeafened = connectionVoiceState?.self_deaf ?? false; const isCameraOn = connectionVoiceState?.self_video ?? false; const isStreaming = connectionVoiceState?.self_stream ?? false; @@ -726,7 +726,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt } if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId)); } else if (currentConnectionId && canUseCurrentConnectionAsRoot) { - const isSelfMuted = currentUserVoiceState?.self_mute ?? false; + const isSelfMuted = VoiceStateCommands.getCurrentSelfMute(); const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false; const isCameraOn = currentConnectionVoiceState?.self_video ?? false; const isStreaming = currentConnectionVoiceState?.self_stream ?? false; @@ -909,7 +909,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt const bulkActions: Array = []; const connectionIds = userVoiceStates.map((u) => u.connectionId); if (isCurrentUser && isCurrentUserConnectedToVoice) { - const allMuted = userVoiceStates.every(({voiceState}) => voiceState.self_mute); + const allMuted = userVoiceStates.every(({connectionId}) => + VoiceStateCommands.getSelfMuteForConnection(connectionId), + ); bulkActions.push({ icon: ( = observer( ({connectionId, isDeviceSpecific = false, label}) => { const {i18n} = useLingui(); - const voiceState = connectionId - ? MediaEngine.getVoiceStateByConnectionId(connectionId) - : MediaEngine.getCurrentUserVoiceState(); - const isSelfMuted = voiceState?.self_mute ?? false; + const isSelfMuted = + isDeviceSpecific && connectionId + ? VoiceStateCommands.getSelfMuteForConnection(connectionId) + : VoiceStateCommands.getCurrentSelfMute(); const handleToggle = useCallback(() => { if (isDeviceSpecific && connectionId) { VoiceStateCommands.toggleSelfMuteForConnection(connectionId); diff --git a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx index 6bd341bd9..1fa58958e 100644 --- a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx +++ b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx @@ -551,6 +551,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() { isGuildDeafened, isPermissionMuted, isPushToTalkEffective, + isSelfMuted: effectiveAudioState.effectiveMute || muteReason === 'self', effectiveMuted, canStream, isCameraEnabled, diff --git a/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.test.ts b/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.test.ts new file mode 100644 index 000000000..0c9d12118 --- /dev/null +++ b/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.test.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + transitionVoiceControlBarState, + type VoiceControlBarSignals, +} from '@app/features/voice/components/VoiceControlBarStateMachine'; +import {describe, expect, it} from 'vitest'; + +function signals(overrides: Partial): VoiceControlBarSignals { + return { + isDeafened: false, + isGuildMuted: false, + isGuildDeafened: false, + isPermissionMuted: false, + isPushToTalkEffective: false, + isSelfMuted: false, + effectiveMuted: false, + canStream: true, + isCameraEnabled: false, + isCameraLimitReached: false, + isCameraUserCapReached: false, + isScreenShareEnabled: false, + screenShareDisabled: false, + ...overrides, + }; +} + +describe('voice control bar mute button in push to talk', () => { + it('enables the toggle and shows the idle gate as unmuted with the hold hint', () => { + expect(transitionVoiceControlBarState(signals({isPushToTalkEffective: true, effectiveMuted: true})).mute).toEqual({ + disabled: false, + pressed: false, + label: 'pushToTalkHoldHint', + }); + }); + + it('offers unmute when self-muted', () => { + expect( + transitionVoiceControlBarState(signals({isPushToTalkEffective: true, isSelfMuted: true, effectiveMuted: true})) + .mute, + ).toEqual({disabled: false, pressed: true, label: 'unmute'}); + }); + + it('keeps server and permission mutes locked', () => { + expect( + transitionVoiceControlBarState(signals({isPushToTalkEffective: true, isGuildMuted: true, effectiveMuted: true})) + .mute, + ).toEqual({disabled: true, pressed: true, label: 'moderatorMuted'}); + expect( + transitionVoiceControlBarState( + signals({isPushToTalkEffective: true, isPermissionMuted: true, effectiveMuted: true}), + ).mute, + ).toEqual({disabled: true, pressed: true, label: 'permissionMuted'}); + expect( + transitionVoiceControlBarState( + signals({isPushToTalkEffective: true, isGuildDeafened: true, effectiveMuted: true}), + ).mute, + ).toEqual({disabled: true, pressed: true, label: 'moderatorDeafened'}); + }); + + it('keeps voice activity labels unchanged', () => { + expect(transitionVoiceControlBarState(signals({})).mute).toEqual({disabled: false, pressed: false, label: 'mute'}); + expect(transitionVoiceControlBarState(signals({isSelfMuted: true, effectiveMuted: true})).mute).toEqual({ + disabled: false, + pressed: true, + label: 'unmute', + }); + }); +}); diff --git a/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.ts b/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.ts index df72f33ec..d60eaa3e1 100644 --- a/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.ts +++ b/fluxer_app/src/features/voice/components/VoiceControlBarStateMachine.ts @@ -19,6 +19,7 @@ export interface VoiceControlBarSignals { isGuildDeafened: boolean; isPermissionMuted: boolean; isPushToTalkEffective: boolean; + isSelfMuted: boolean; effectiveMuted: boolean; canStream: boolean; isCameraEnabled: boolean; @@ -54,6 +55,7 @@ const DEFAULT_SIGNALS: VoiceControlBarSignals = { isGuildDeafened: false, isPermissionMuted: false, isPushToTalkEffective: false, + isSelfMuted: false, effectiveMuted: false, canStream: true, isCameraEnabled: false, @@ -74,7 +76,7 @@ function selectMuteLabel(signals: VoiceControlBarSignals): VoiceControlBarMuteLa if (signals.isGuildDeafened) return 'moderatorDeafened'; if (signals.isGuildMuted) return 'moderatorMuted'; if (signals.isPermissionMuted) return 'permissionMuted'; - if (signals.isPushToTalkEffective) return 'pushToTalkHoldHint'; + if (signals.isPushToTalkEffective && !signals.isSelfMuted) return 'pushToTalkHoldHint'; return signals.effectiveMuted ? 'unmute' : 'mute'; } @@ -97,11 +99,10 @@ function selectScreenShareLabel(signals: VoiceControlBarSignals): VoiceControlBa function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState { const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted; - const isMuteToggleLocked = isMicLocked || signals.isPushToTalkEffective; return { mute: { - disabled: isMuteToggleLocked, - pressed: signals.effectiveMuted, + disabled: isMicLocked, + pressed: signals.isPushToTalkEffective ? isMicLocked || signals.isSelfMuted : signals.effectiveMuted, label: selectMuteLabel(signals), }, deafen: { diff --git a/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx b/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx index aa1810768..3135a51eb 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx @@ -104,7 +104,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto useMediaEngineVersion(); const callHeaderState = useCallHeaderState(channel); const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null); - const localSelfMute = LocalVoiceState.getSelfMute(); + const localSelfMute = VoiceStateCommands.getCurrentSelfMute(); const localSelfDeaf = LocalVoiceState.getSelfDeaf(); const localSelfVideo = LocalVoiceState.getSelfVideo(); const currentLatency = MediaEngine.currentLatency; @@ -112,7 +112,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto const voiceServerEndpoint = MediaEngine.voiceServerEndpoint; const connectionId = MediaEngine.connectionId; const isConnected = MediaEngine.connected && MediaEngine.channelId === channel.id; - const isMuted = voiceState ? voiceState.self_mute : localSelfMute; + const isMuted = localSelfMute; const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf; const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo; const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn); diff --git a/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx b/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx index 0e547d150..843c466d6 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx @@ -85,7 +85,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({ const connectedGuildId = MediaEngine.guildId; const connectedChannelId = MediaEngine.channelId; const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId); - const localSelfMute = LocalVoiceState.getSelfMute(); + const localSelfMute = VoiceStateCommands.getCurrentSelfMute(); const localSelfDeaf = LocalVoiceState.getSelfDeaf(); const localSelfVideo = LocalVoiceState.getSelfVideo(); const currentLatency = MediaEngine.currentLatency; @@ -96,8 +96,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({ const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id); const isGuildMuted = voiceState?.mute ?? false; const isGuildDeafened = voiceState?.deaf ?? false; - const isMuted = - (voiceState ? voiceState.self_mute : localSelfMute) || isGuildMuted || isGuildDeafened || isPermissionMuted; + const isMuted = localSelfMute || isGuildMuted || isGuildDeafened || isPermissionMuted; const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened; const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo; const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn); diff --git a/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts b/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts index 22ab80d13..c95b34ca0 100644 --- a/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts +++ b/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts @@ -144,7 +144,7 @@ import voiceEngineV2AppScreenShareExecutionAdapter, { type ScreenShareReconnectSnapshot, shouldRestoreScreenShareAfterReconnect, } from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter'; -import {selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; +import {selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; import {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge'; import {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter'; import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter'; @@ -1503,7 +1503,7 @@ class MediaEngineFacade extends Store { private getEffectiveSelfMuteForVoiceStatePayload(): boolean { this.syncVoiceEngineV2AudioControlsFromAppState(); - return selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot); + return selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot); } private cancelPendingServerDisconnect(): void { @@ -1534,7 +1534,10 @@ class MediaEngineFacade extends Store { raw.connection_id, ); LocalVoiceState.seedConnectionState(raw.connection_id, { - selfMute: currentVoiceState?.self_mute ?? LocalVoiceState.getSelfMute(), + selfMute: + this.isPushToTalkActive() || this.isPushToMuteActive() + ? LocalVoiceState.getSelfMute() + : (currentVoiceState?.self_mute ?? LocalVoiceState.getSelfMute()), selfDeaf: currentVoiceState?.self_deaf ?? LocalVoiceState.getSelfDeaf(), selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(), selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(), @@ -2041,7 +2044,10 @@ class MediaEngineFacade extends Store { selfStream: payload.self_stream, }); if (partial?.viewer_stream_keys !== undefined) { - syncVoiceStateToServer(guildId, channelId, connectionId, {viewer_stream_keys: payload.viewer_stream_keys}); + syncVoiceStateToServer(guildId, channelId, connectionId, { + self_mute: payload.self_mute, + viewer_stream_keys: payload.viewer_stream_keys, + }); } } @@ -2302,12 +2308,8 @@ class MediaEngineFacade extends Store { this.syncVoiceEngineV2AudioControlsFromAppState(); } - handlePushToTalkModeChange(options: {preserveSelfMute?: boolean} = {}): void { - voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange( - this.room, - () => this.getCurrentUserVoiceState(), - options, - ); + handlePushToTalkModeChange(): void { + voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(this.room, () => this.getCurrentUserVoiceState()); this.syncVoiceEngineV2AudioControlsFromAppState(); } @@ -2387,11 +2389,9 @@ class MediaEngineFacade extends Store { requestMicrophoneRefresh: (targetRoom, request) => voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request), configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom), - handleInputKeybindChange: (targetRoom, options) => - voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange( - targetRoom, - () => this.getCurrentUserVoiceState(), - options, + handleInputKeybindChange: (targetRoom) => + voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(targetRoom, () => + this.getCurrentUserVoiceState(), ), applyAllLocalAudioPreferences: () => voiceEngineV2AppMediaExecutionAdapter.applyAllLocalAudioPreferences(room), }, diff --git a/fluxer_app/src/features/voice/engine/VoiceChannelConnector.test.ts b/fluxer_app/src/features/voice/engine/VoiceChannelConnector.test.ts new file mode 100644 index 000000000..8c7f699f7 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/VoiceChannelConnector.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; +import { + availableVoiceEngineV2Capabilities, + createVoiceEngineV2InitialSnapshot, + type VoiceEngineV2Snapshot, +} from '@fluxer/voice_engine_v2/src/core/state'; +import type {VoiceEngineV2AudioControlsPatch} from '@fluxer/voice_engine_v2/src/protocol/types'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const updateVoiceStateExplicit = vi.fn(); +const engine: {snapshot: VoiceEngineV2Snapshot | null} = {snapshot: null}; + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); + +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}), +})); + +vi.mock('@app/features/gateway/transport/GatewaySessionPool', () => ({ + default: {foregroundConnection: {socket: {updateVoiceStateExplicit}}}, +})); + +vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({ + getAllVoiceStatesInChannelFromMediaEngine: () => ({}), + getVoiceConnectionContextFromMediaEngine: () => ({connectionId: 'local'}), + getVoiceEngineV2SnapshotFromMediaEngine: () => engine.snapshot, +})); + +vi.mock('@app/features/voice/utils/VoicePermissionUtils', () => ({ + applyVoiceSpeakPermissionToSelfMute: (_guildId: string | null, _channelId: string, selfMute: boolean) => selfMute, +})); + +vi.mock('@app/features/voice/components/alerts/VoiceChannelFullModal', () => ({VoiceChannelFullModal: () => null})); +vi.mock('@app/features/voice/components/alerts/VoiceConnectionConfirmModal', () => ({ + VoiceConnectionConfirmModal: () => null, +})); +vi.mock('@app/features/voice/components/alerts/VoiceP2pConsentModal', () => ({VoiceP2pConsentModal: () => null})); + +const {syncVoiceStateToServer} = await import('@app/features/voice/engine/VoiceChannelConnector'); + +function snapshotWith(controls: VoiceEngineV2AudioControlsPatch): VoiceEngineV2Snapshot { + return transitionVoiceEngineV2(createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()), { + type: 'audioControls.changed', + controls, + }).snapshot; +} + +function lastSelfMute(): boolean { + const [payload] = updateVoiceStateExplicit.mock.lastCall as [{self_mute: boolean}]; + return payload.self_mute; +} + +describe('direct gateway voice state sync with push to talk', () => { + beforeEach(() => { + updateVoiceStateExplicit.mockClear(); + }); + + it('reports self_mute while the push to talk key is up on a viewer stream key sync', () => { + engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: false, locallyMuted: false}); + syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []}); + expect(lastSelfMute()).toBe(true); + }); + + it('reports unmuted while the push to talk key is held', () => { + engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: false}); + syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []}); + expect(lastSelfMute()).toBe(false); + }); + + it('reports self-mute over a held push to talk key', () => { + engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: true}); + syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []}); + expect(lastSelfMute()).toBe(true); + }); + + it('keeps voice activity unmuted and prefers an explicit self_mute', () => { + engine.snapshot = snapshotWith({mode: 'voiceActivity', locallyMuted: false}); + syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []}); + expect(lastSelfMute()).toBe(false); + syncVoiceStateToServer('1', '2', 'local', {self_mute: true}); + expect(lastSelfMute()).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/VoiceChannelConnector.tsx b/fluxer_app/src/features/voice/engine/VoiceChannelConnector.tsx index c2819d0cd..be0d60bb8 100644 --- a/fluxer_app/src/features/voice/engine/VoiceChannelConnector.tsx +++ b/fluxer_app/src/features/voice/engine/VoiceChannelConnector.tsx @@ -16,7 +16,7 @@ import { type VoiceMediaEngineVoiceState, } from '@app/features/voice/engine/VoiceMediaEngineBridge'; import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes'; -import {selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; +import {selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; import {isChannelP2p} from '@app/features/voice/state/ChannelP2pStatus'; import LocalVoiceState from '@app/features/voice/state/LocalVoiceState'; import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent'; @@ -180,7 +180,7 @@ export function sendVoiceStateConnect( LocalVoiceState.ensurePermissionMute(); const snapshot = getVoiceEngineV2SnapshotFromMediaEngine(); const effectiveSelfMute = snapshot - ? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot) + ? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot) : LocalVoiceState.getSelfMute(); const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute); const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null; @@ -227,7 +227,7 @@ export function syncVoiceStateToServer( if (!socket) return; const snapshot = getVoiceEngineV2SnapshotFromMediaEngine(); const effectiveSelfMute = snapshot - ? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot) + ? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot) : LocalVoiceState.getSelfMute(); const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute); socket.updateVoiceStateExplicit({ diff --git a/fluxer_app/src/features/voice/engine/VoiceLocalSpeakingGate.test.ts b/fluxer_app/src/features/voice/engine/VoiceLocalSpeakingGate.test.ts new file mode 100644 index 000000000..1b15a122b --- /dev/null +++ b/fluxer_app/src/features/voice/engine/VoiceLocalSpeakingGate.test.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type LocalSpeakingOverrideInput, + resolveLocalSpeakingOverrideState, +} from '@app/features/voice/engine/VoiceLocalSpeakingGate'; +import {describe, expect, it} from 'vitest'; + +function input(overrides: Partial): LocalSpeakingOverrideInput { + return { + pushToTalkActive: true, + pushToMuteActive: false, + pushToMuteHeld: false, + selfDeaf: false, + effectiveSelfMute: false, + hasMicrophonePublication: true, + microphonePublicationMuted: false, + ...overrides, + }; +} + +describe('resolveLocalSpeakingOverrideState in push to talk', () => { + it('never lights the speaking indicator for a self-muted user holding the key', () => { + expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true}))).toBe(false); + expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true, microphonePublicationMuted: true}))).toBe( + false, + ); + }); + + it('lights the speaking indicator for an unmuted user holding the key', () => { + expect(resolveLocalSpeakingOverrideState(input({}))).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.test.ts new file mode 100644 index 000000000..eca5b1f7a --- /dev/null +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Room} from 'livekit-client'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); + +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}), +})); + +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}})); + +const {default: Keybind} = await import('@app/features/input/state/InputKeybind'); +const {isVoiceActivityGateEnabled} = await import('@app/features/voice/utils/VoiceInputProcessor'); +const {bindVoiceEngineV2AppAudioPreferencesSync} = await import( + '@app/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding' +); + +function createAdapter() { + return { + requestMicrophoneRefresh: vi.fn(async () => {}), + configureVoiceInput: vi.fn(), + handleInputKeybindChange: vi.fn(), + applyAllLocalAudioPreferences: vi.fn(), + }; +} + +describe('voice engine v2 audio preferences sync binding', () => { + let dispose: (() => void) | null = null; + + afterEach(() => { + dispose?.(); + dispose = null; + Keybind.setTransmitMode('voice_activity'); + }); + + it('reconfigures voice input when transmit mode flips without a push-to-mute keybind', () => { + Keybind.setTransmitMode('voice_activity'); + expect(Keybind.isPushToMuteEffective()).toBe(false); + const gateBefore = isVoiceActivityGateEnabled(); + const adapter = createAdapter(); + dispose = bindVoiceEngineV2AppAudioPreferencesSync({} as Room, adapter, {warn: vi.fn()}); + + Keybind.setTransmitMode('voice_push_to_talk'); + + expect(isVoiceActivityGateEnabled()).toBe(false); + expect(gateBefore).not.toBe(isVoiceActivityGateEnabled()); + expect(adapter.configureVoiceInput).toHaveBeenCalledTimes(1); + expect(adapter.handleInputKeybindChange).not.toHaveBeenCalled(); + + Keybind.setTransmitMode('voice_activity'); + + expect(isVoiceActivityGateEnabled()).toBe(gateBefore); + expect(adapter.configureVoiceInput).toHaveBeenCalledTimes(2); + expect(adapter.handleInputKeybindChange).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.ts index 9d3751bab..b90eeeff8 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding.ts @@ -23,14 +23,13 @@ interface VoiceEngineV2AppAudioPreferencesSnapshot { readonly participantMutes: Readonly>; readonly connectionVolumes: Readonly>>>; readonly streamAudioRevision: number; - readonly transmitMode: typeof Keybind.transmitMode; readonly pushToMuteActive: boolean; } export interface VoiceEngineV2AppAudioPreferencesMediaAdapter { requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise; configureVoiceInput(room: Room): void; - handleInputKeybindChange(room: Room, options: {preserveSelfMute: boolean}): void; + handleInputKeybindChange(room: Room): void; applyAllLocalAudioPreferences(room: Room): void; } @@ -50,7 +49,6 @@ function createVoiceEngineV2AppAudioPreferencesSnapshot(): VoiceEngineV2AppAudio participantMutes: ParticipantVolume.localMutes, connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId, streamAudioRevision: StreamAudioPrefs.audioPrefsRevision, - transmitMode: Keybind.transmitMode, pushToMuteActive: Keybind.isPushToMuteEffective(), }; } @@ -97,7 +95,7 @@ function syncVoiceEngineV2AppAudioPreferences( ); assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn'); if (previous.pushToMuteActive !== current.pushToMuteActive) { - adapter.handleInputKeybindChange(room, {preserveSelfMute: previous.transmitMode === current.transmitMode}); + adapter.handleInputKeybindChange(room); } const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings); if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) { diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx index 67c486839..4ea382cdc 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx @@ -540,15 +540,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store { await this.enforceSpeakPermissionMute(room); return; } - const pttActive = isPushToTalkActiveFromAppState(); - const ptmActive = isPushToMuteActiveFromAppState(); - const effectiveMute = - audioState.effectiveDeaf || - audioState.serverMute || - audioState.selfMute || - (pttActive && !Keybind.pushToTalkHeld) || - (ptmActive && Keybind.pushToMuteHeld); - if (effectiveMute) { + if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) { this.setAudioPublicationsMuted(room, true, 'voice state update'); this.syncLocalSpeakingOverride(room); return; @@ -1506,7 +1498,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store { serverDeaf: serverVoiceState?.deaf, }); if (isMutedOrDeafened(audioState)) return; - const targetMute = audioState.selfMute || !held; + const targetMute = getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls(); const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk'); if (!ok) { this.syncVoiceState({self_mute: true}); @@ -1545,11 +1537,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store { this.syncLocalSpeakingOverride(room); } - handlePushToTalkModeChange( - room: Room | null, - getCurrentUserVoiceState: () => VoiceState | null, - options: {preserveSelfMute?: boolean} = {}, - ): void { + handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void { assertNullableObjectLike(room, 'handlePushToTalkModeChange.room'); assert.equal( typeof getCurrentUserVoiceState, @@ -1558,9 +1546,6 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store { ); Keybind.resetPushToTalkState(); Keybind.resetPushToMuteState(); - if (!options.preserveSelfMute && isPushToTalkActiveFromAppState() && LocalVoiceState.getSelfMute()) { - LocalVoiceState.updateSelfMute(false); - } const serverVoiceState = getCurrentUserVoiceState(); const audioState = this.getEffectiveAudioState({ serverMute: serverVoiceState?.mute ?? false, diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPushToTalkSelfMute.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPushToTalkSelfMute.test.ts new file mode 100644 index 000000000..05c82a108 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPushToTalkSelfMute.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Room} from 'livekit-client'; +import {Track} from 'livekit-client'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const syncLocalVoiceStateWithServer = vi.fn(); +const setVoiceEngineV2ParticipantAudioLevelSpeaking = vi.fn(() => true); + +const keybind = { + pushToTalkEnabled: true, + pushToTalkHeld: false, + pushToMuteHeld: false, + isPushToTalkEffective: () => keybind.pushToTalkEnabled, + isPushToMuteEffective: () => false, + setPushToTalkHeld: (held: boolean) => { + keybind.pushToTalkHeld = held; + }, + setPushToMuteHeld: (held: boolean) => { + keybind.pushToMuteHeld = held; + }, + resetPushToTalkState: () => { + keybind.pushToTalkHeld = false; + }, + resetPushToMuteState: () => { + keybind.pushToMuteHeld = false; + }, +}; + +const localVoiceState = { + selfMute: false, + selfDeaf: false, + shouldUnmuteOnUndeafen: false, + getSelfMute: () => localVoiceState.selfMute, + getSelfDeaf: () => localVoiceState.selfDeaf, + getMutedByPermission: () => false, + getHasUserSetMute: () => true, + getHasUserSetDeaf: () => false, + updateSelfMute: (muted: boolean) => { + localVoiceState.selfMute = muted; + }, + updateSelfDeaf: (deafened: boolean) => { + localVoiceState.selfDeaf = deafened; + }, +}; + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); + +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}), +})); + +vi.mock('@app/app/I18n', () => ({default: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}})); + +vi.mock('@app/features/input/state/InputKeybind', () => ({default: keybind})); + +vi.mock('@app/features/voice/state/LocalVoiceState', () => ({default: localVoiceState})); + +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}, useMediaEngineVersion: () => 0})); + +vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({ + getRoomFromMediaEngine: () => null, + getVoiceConnectionContextFromMediaEngine: () => null, + getVoiceEngineV2SnapshotFromMediaEngine: () => null, + getVoiceStateByConnectionIdFromMediaEngine: () => null, + setVoiceEngineV2ParticipantAudioLevelSpeaking: (...args: Array) => + setVoiceEngineV2ParticipantAudioLevelSpeaking(...(args as [])), + syncLocalVoiceStateWithServer: (...args: Array) => syncLocalVoiceStateWithServer(...args), + updateLocalParticipantFromRoom: () => undefined, +})); + +const {installRuntimeBootstrap} = await import('@app/features/platform/state/__fixtures__/RuntimeBootstrapFixture'); +installRuntimeBootstrap('https://primary.test/api'); + +const {default: adapter} = await import('@app/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter'); + +interface FakeMicrophonePublication { + source: Track.Source; + isMuted: boolean; + track: {mediaStreamTrack: {readyState: string}}; + mute: ReturnType; + unmute: ReturnType; +} + +function createMicrophonePublication(): FakeMicrophonePublication { + const publication: FakeMicrophonePublication = { + source: Track.Source.Microphone, + isMuted: true, + track: {mediaStreamTrack: {readyState: 'live'}}, + mute: vi.fn(async () => { + publication.isMuted = true; + }), + unmute: vi.fn(async () => { + publication.isMuted = false; + }), + }; + return publication; +} + +function createRoom(): {room: Room; publications: Map} { + const publications = new Map(); + const room = { + localParticipant: { + identity: 'user_1_connection', + audioTrackPublications: publications, + trackPublications: publications, + }, + remoteParticipants: new Map(), + } as unknown as Room; + return {room, publications}; +} + +function lastSyncedSelfMute(): unknown { + const call = syncLocalVoiceStateWithServer.mock.calls.at(-1); + return (call?.[0] as {self_mute?: boolean} | undefined)?.self_mute; +} + +function lastSpeakingOverride(): unknown { + return setVoiceEngineV2ParticipantAudioLevelSpeaking.mock.calls.at(-1)?.[1 as never]; +} + +const noServerVoiceState = () => null; + +describe('push to talk with self-mute', () => { + beforeEach(() => { + keybind.pushToTalkEnabled = true; + keybind.pushToTalkHeld = false; + localVoiceState.selfMute = false; + localVoiceState.selfDeaf = false; + syncLocalVoiceStateWithServer.mockClear(); + setVoiceEngineV2ParticipantAudioLevelSpeaking.mockClear(); + }); + + it('keeps the microphone muted and reports self_mute while the key is held and the user is self-muted', () => { + const {room, publications} = createRoom(); + const publication = createMicrophonePublication(); + publications.set('mic', publication); + localVoiceState.selfMute = true; + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(keybind.pushToTalkHeld).toBe(true); + expect(publication.unmute).not.toHaveBeenCalled(); + expect(publication.mute).toHaveBeenCalled(); + expect(publication.isMuted).toBe(true); + expect(lastSyncedSelfMute()).toBe(true); + expect(lastSpeakingOverride()).toBe(false); + adapter.applyPushToTalkHold(false, room, noServerVoiceState); + expect(publication.unmute).not.toHaveBeenCalled(); + expect(lastSyncedSelfMute()).toBe(true); + }); + + it('gates the microphone on the key again once the user unmutes', () => { + const {room, publications} = createRoom(); + const publication = createMicrophonePublication(); + publications.set('mic', publication); + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(publication.unmute).toHaveBeenCalledTimes(1); + expect(lastSyncedSelfMute()).toBe(false); + expect(lastSpeakingOverride()).toBe(true); + adapter.applyPushToTalkHold(false, room, noServerVoiceState); + expect(publication.isMuted).toBe(true); + expect(lastSyncedSelfMute()).toBe(true); + expect(lastSpeakingOverride()).toBe(false); + }); + + it('transmits when the user unmutes while the key is still held', () => { + const {room, publications} = createRoom(); + const publication = createMicrophonePublication(); + publications.set('mic', publication); + localVoiceState.selfMute = true; + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(publication.isMuted).toBe(true); + localVoiceState.selfMute = false; + adapter.handlePushToTalkModeChange(room, noServerVoiceState); + expect(keybind.pushToTalkHeld).toBe(false); + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(publication.isMuted).toBe(false); + expect(lastSyncedSelfMute()).toBe(false); + }); + + it('keeps self-mute when switching into and out of push to talk', () => { + const {room, publications} = createRoom(); + const publication = createMicrophonePublication(); + publications.set('mic', publication); + localVoiceState.selfMute = true; + keybind.pushToTalkEnabled = false; + adapter.handlePushToTalkModeChange(room, noServerVoiceState); + expect(lastSyncedSelfMute()).toBe(true); + keybind.pushToTalkEnabled = true; + adapter.handlePushToTalkModeChange(room, noServerVoiceState); + expect(localVoiceState.selfMute).toBe(true); + expect(publication.unmute).not.toHaveBeenCalled(); + expect(lastSyncedSelfMute()).toBe(true); + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(publication.unmute).not.toHaveBeenCalled(); + expect(lastSyncedSelfMute()).toBe(true); + keybind.pushToTalkEnabled = false; + adapter.handlePushToTalkModeChange(room, noServerVoiceState); + expect(localVoiceState.selfMute).toBe(true); + expect(publication.unmute).not.toHaveBeenCalled(); + expect(lastSyncedSelfMute()).toBe(true); + }); + + it('publishes a muted microphone when a self-muted push to talk user unmutes without holding the key', async () => { + const {room, publications} = createRoom(); + const enableMicrophone = vi.spyOn(adapter, 'enableMicrophone').mockImplementation(async () => { + publications.set('mic', createMicrophonePublication()); + }); + localVoiceState.selfMute = true; + await adapter.reconcileEffectiveAudioState(room, {channelId: '1', serverMute: false, serverDeaf: false}); + expect(enableMicrophone).not.toHaveBeenCalled(); + expect(publications.size).toBe(0); + localVoiceState.selfMute = false; + await adapter.reconcileEffectiveAudioState(room, {channelId: '1', serverMute: false, serverDeaf: false}); + expect(enableMicrophone).toHaveBeenCalledTimes(1); + const publication = publications.get('mic'); + expect(publication).toBeDefined(); + expect(publication?.isMuted).toBe(true); + expect(publication?.unmute).not.toHaveBeenCalled(); + adapter.applyPushToTalkHold(true, room, noServerVoiceState); + expect(publication?.unmute).toHaveBeenCalledTimes(1); + expect(publication?.isMuted).toBe(false); + expect(lastSyncedSelfMute()).toBe(false); + enableMicrophone.mockRestore(); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.test.ts new file mode 100644 index 000000000..28c8aa715 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.test.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls, + selectVoiceEngineV2AppMuteReason, + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload, +} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors'; +import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; +import { + availableVoiceEngineV2Capabilities, + createVoiceEngineV2InitialSnapshot, + type VoiceEngineV2Snapshot, +} from '@fluxer/voice_engine_v2/src/core/state'; +import type { + VoiceEngineV2AudioControls, + VoiceEngineV2AudioControlsPatch, +} from '@fluxer/voice_engine_v2/src/protocol/types'; +import {describe, expect, it} from 'vitest'; + +function audio(overrides: Partial): VoiceEngineV2AudioControls { + return { + mode: 'pushToTalk', + locallyMuted: false, + preferredLocallyMuted: false, + locallyDeafened: false, + mutedByPermission: false, + hasUserSetMute: false, + hasUserSetDeaf: false, + shouldUnmuteOnUndeafen: false, + pushToTalkActive: false, + pushToMuteActive: false, + inputVolume: 100, + outputVolume: 100, + ...overrides, + }; +} + +function snapshotWith(controls: VoiceEngineV2AudioControlsPatch): VoiceEngineV2Snapshot { + return transitionVoiceEngineV2(createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()), { + type: 'audioControls.changed', + controls, + }).snapshot; +} + +describe('push to talk self-mute selectors', () => { + it('reports self-mute over a held push to talk key', () => { + const controls = audio({locallyMuted: true, pushToTalkActive: true}); + expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(controls)).toBe(true); + expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: controls})).toBe('self'); + }); + + it('reports the push to talk gate when not self-muted', () => { + const idle = audio({}); + expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(idle)).toBe(true); + expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: idle})).toBe( + 'voice_push_to_talk', + ); + const held = audio({pushToTalkActive: true}); + expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(held)).toBe(false); + expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: held})).toBeNull(); + }); + + it('syncs self_mute from self-mute and the push to talk gate', () => { + expect(selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'pushToTalk'}))).toBe(true); + expect( + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload( + snapshotWith({mode: 'pushToTalk', pushToTalkActive: true}), + ), + ).toBe(false); + expect( + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload( + snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: true}), + ), + ).toBe(true); + }); + + it('keeps voice activity and permission mutes in the synced self_mute', () => { + expect(selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'voiceActivity'}))).toBe(false); + expect( + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'voiceActivity', locallyMuted: true})), + ).toBe(true); + expect( + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload( + snapshotWith({mode: 'voiceActivity', mutedByPermission: true}), + ), + ).toBe(true); + expect( + selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload( + snapshotWith({mode: 'pushToMute', pushToMuteActive: true}), + ), + ).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.ts index 495f3c1b9..dd99de419 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppSelectors.ts @@ -211,11 +211,15 @@ export function selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload( return selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio); } -export function selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload( +export function selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload( source: VoiceEngineV2AppProjectionSource, ): boolean { assertNonNullObject(source, 'source'); - return selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio); + const audio = selectVoiceEngineV2AppLocalMedia(source).audio; + return ( + selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio) || + selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(audio) + ); } function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean { diff --git a/packages/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json b/packages/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json new file mode 100644 index 000000000..2f5dc22a1 --- /dev/null +++ b/packages/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json @@ -0,0 +1,76 @@ +{ + "name": "self-mute stays authoritative while push to talk is held and across mode switches", + "events": [ + {"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}}, + {"type": "connection.connectSucceeded", "operationId": 1}, + {"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}}, + {"type": "microphone.publishSucceeded", "operationId": 2}, + {"type": "localAudio.muteRequested", "muted": true}, + {"type": "microphone.setEnabledSucceeded", "operationId": 3}, + {"type": "audioControls.changed", "controls": {"mode": "pushToTalk", "pushToTalkActive": false}}, + {"type": "audioControls.changed", "controls": {"pushToTalkActive": true}}, + {"type": "localAudio.muteRequested", "muted": false}, + {"type": "microphone.setEnabledSucceeded", "operationId": 4}, + {"type": "audioControls.changed", "controls": {"pushToTalkActive": false}}, + {"type": "microphone.setEnabledSucceeded", "operationId": 5}, + {"type": "localAudio.muteRequested", "muted": true}, + {"type": "audioControls.changed", "controls": {"pushToTalkActive": true}}, + {"type": "audioControls.changed", "controls": {"mode": "voiceActivity", "pushToTalkActive": false}} + ], + "expectedCommands": [ + {"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}}, + {"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}}, + {"type": "microphone.setEnabled", "operationId": 3, "enabled": false}, + {"type": "microphone.setEnabled", "operationId": 4, "enabled": true}, + {"type": "microphone.setEnabled", "operationId": 5, "enabled": false} + ], + "checkpoints": [ + { + "afterEvent": 7, + "microphoneEnabled": false, + "localSpeakingOverride": false, + "audioMode": "pushToTalk", + "locallyMuted": true + }, + { + "afterEvent": 8, + "microphoneEnabled": false, + "localSpeakingOverride": false, + "audioMode": "pushToTalk", + "locallyMuted": true + }, + { + "afterEvent": 10, + "microphoneEnabled": true, + "localSpeakingOverride": true, + "audioMode": "pushToTalk", + "locallyMuted": false + }, + { + "afterEvent": 12, + "microphoneEnabled": false, + "localSpeakingOverride": false, + "audioMode": "pushToTalk", + "locallyMuted": false + }, + { + "afterEvent": 14, + "microphoneEnabled": false, + "localSpeakingOverride": false, + "audioMode": "pushToTalk", + "locallyMuted": true + } + ], + "expectedSnapshot": { + "nextOperationId": 6, + "connectionStatus": "connected", + "microphoneStatus": "published", + "microphoneEnabled": false, + "localSpeakingOverride": false, + "audioMode": "voiceActivity", + "locallyMuted": true, + "preferredLocallyMuted": true, + "publishedDeviceId": "mic-a", + "lastFailureCode": null + } +} diff --git a/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts b/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts index 56935993a..190223a77 100644 --- a/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts +++ b/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts @@ -5,6 +5,7 @@ import localMediaFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/l import microphoneFailureRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/microphone_failure_recovery.json'; import permissionsRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/permissions_recovery.json'; import pttPtmFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/ptt_ptm.json'; +import pttSelfMuteFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json'; import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; import { availableVoiceEngineV2Capabilities, @@ -49,6 +50,7 @@ interface LocalMediaFixture { const fixtures = [ localMediaFixtureJson, pttPtmFixtureJson, + pttSelfMuteFixtureJson, deviceHotswapFixtureJson, permissionsRecoveryFixtureJson, microphoneFailureRecoveryFixtureJson,