fix(voice): allow self-mute while push-to-talk is on (#3451)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 22:02:40 +02:00
1 parent 3657438467
commit f501d34179
27 files changed
+1025 -79

No files matched your search

@@ -206,6 +206,7 @@ const UserAreaInner = observer(
}, [hasVoiceConnection]); }, [hasVoiceConnection]);
const wrapperClassName = styles.userAreaInnerWrapper; const wrapperClassName = styles.userAreaInnerWrapper;
const isPushToTalkEffective = Keybind.isPushToTalkEffective(); const isPushToTalkEffective = Keybind.isPushToTalkEffective();
const isSelfMuted = isMuted || muteReason === 'self';
const microphoneState = selectUserAreaMicrophoneState({ const microphoneState = selectUserAreaMicrophoneState({
effectiveAudioMuted: isMuted, effectiveAudioMuted: isMuted,
effectiveAudioDeafened: isDeafened, effectiveAudioDeafened: isDeafened,
@@ -214,7 +215,6 @@ const UserAreaInner = observer(
isPermissionMuted, isPermissionMuted,
muteReason, muteReason,
isPushToTalkEffective, isPushToTalkEffective,
isPushToTalkHeld: Keybind.pushToTalkHeld,
isPushToMuteEffective: Keybind.isPushToMuteEffective(), isPushToMuteEffective: Keybind.isPushToMuteEffective(),
isPushToMuteHeld: Keybind.pushToMuteHeld, isPushToMuteHeld: Keybind.pushToMuteHeld,
}); });
@@ -224,7 +224,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true); if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR); if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_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); if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR); return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})(); })();
@@ -232,7 +233,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true); if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR); if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_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); if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR); return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})(); })();
@@ -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>): 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,
});
});
});
@@ -10,7 +10,6 @@ export interface UserAreaMicrophoneSignals {
isPermissionMuted: boolean; isPermissionMuted: boolean;
muteReason: UserAreaMuteReason; muteReason: UserAreaMuteReason;
isPushToTalkEffective: boolean; isPushToTalkEffective: boolean;
isPushToTalkHeld: boolean;
isPushToMuteEffective: boolean; isPushToMuteEffective: boolean;
isPushToMuteHeld: boolean; isPushToMuteHeld: boolean;
} }
@@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals
if (hardMuted) { if (hardMuted) {
return { return {
effectiveMuted: true, effectiveMuted: true,
muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective, muteToggleLocked,
}; };
} }
if (signals.isPushToTalkEffective) { if (signals.isPushToTalkEffective) {
return { return {
effectiveMuted: !signals.isPushToTalkHeld, effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self',
muteToggleLocked: true, muteToggleLocked,
}; };
} }
return { return {
@@ -521,7 +521,7 @@ class KeybindManager {
}), }),
); );
} }
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options))); this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange()));
this.disposers.push( this.disposers.push(
reaction( reaction(
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}), () => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
@@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => {
dispose(); 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 {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
const reconcile = vi.fn(); const reconcile = vi.fn();
const dispose = reactToPushToTalkModeChanges(reconcile); const dispose = reactToPushToTalkModeChanges(reconcile);
@@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => {
emit(status); emit(status);
expect(Keybind.isPushToTalkEffective()).toBe(effective); expect(Keybind.isPushToTalkEffective()).toBe(effective);
expect(reconcile).toHaveBeenCalledTimes(1); expect(reconcile).toHaveBeenCalledTimes(1);
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true}); expect(reconcile).toHaveBeenLastCalledWith();
}; };
expectFlip(portalStatus('declined'), false); expectFlip(portalStatus('declined'), false);
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]); expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
@@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => {
expectFlip(portalStatus('bound'), true); expectFlip(portalStatus('bound'), true);
reconcile.mockClear(); reconcile.mockClear();
Keybind.setTransmitMode('voice_activity'); 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'); Keybind.setTransmitMode('voice_push_to_talk');
expect(Keybind.isPushToTalkEffective()).toBe(true);
expect(reconcile).toHaveBeenCalledTimes(2);
dispose(); dispose();
}); });
}); });
@@ -3,14 +3,13 @@
import Keybind from '@app/features/input/state/InputKeybind'; import Keybind from '@app/features/input/state/InputKeybind';
import {compareStructural, reaction} from 'mobx'; import {compareStructural, reaction} from 'mobx';
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void { export function reactToPushToTalkModeChanges(onChange: () => void): () => void {
return reaction( return reaction(
() => ({ () => ({
transmitMode: Keybind.transmitMode,
pushToTalk: Keybind.isPushToTalkEffective(), pushToTalk: Keybind.isPushToTalkEffective(),
pushToMute: Keybind.isPushToMuteEffective(), pushToMute: Keybind.isPushToMuteEffective(),
}), }),
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}), () => onChange(),
{equals: compareStructural}, {equals: compareStructural},
); );
} }
@@ -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<unknown>) => 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}),
);
});
});
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool'; 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 {SoundType} from '@app/features/notification/utils/SoundUtils';
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands'; import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
import MediaPermission from '@app/features/permissions/system/state/MediaPermission'; import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
@@ -119,14 +118,10 @@ const requestMicrophoneDirectly = async (): Promise<boolean> => {
}; };
export async function toggleSelfMute(_guildId: string | null = null): Promise<void> { export async function toggleSelfMute(_guildId: string | null = null): Promise<void> {
if (Keybind.isPushToTalkEffective()) {
logger.debug('Ignoring self-mute toggle: push-to-talk controls the microphone');
return;
}
const room = MediaEngine.room; const room = MediaEngine.room;
const connectedChannelId = MediaEngine.channelId; const connectedChannelId = MediaEngine.channelId;
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched(); const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched; const currentMute = getCurrentSelfMute();
const currentDeaf = LocalVoiceState.getSelfDeaf(); const currentDeaf = LocalVoiceState.getSelfDeaf();
const willUndeafen = currentDeaf; const willUndeafen = currentDeaf;
const willUnmute = currentMute; 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<void> { export async function toggleSelfMuteForConnection(connectionId: string): Promise<void> {
if (connectionId === MediaEngine.connectionId) {
await toggleSelfMute(null);
return;
}
const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId); const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId);
if (!voiceState) return; if (!voiceState) return;
const target = !voiceState.self_mute; const target = !voiceState.self_mute;
@@ -265,7 +273,16 @@ export async function turnOffStreamForConnection(connectionId: string): Promise<
} }
export async function bulkMuteConnections(connectionIds: Array<string>, mute: boolean = true): Promise<void> { export async function bulkMuteConnections(connectionIds: Array<string>, mute: boolean = true): Promise<void> {
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<string>, deafen: boolean = true): Promise<void> { export async function bulkDeafenConnections(connectionIds: Array<string>, deafen: boolean = true): Promise<void> {
@@ -667,7 +667,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
const useConnectionSpecificControls = const useConnectionSpecificControls =
connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId); connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId);
if (useConnectionSpecificControls) { if (useConnectionSpecificControls) {
const isSelfMuted = connectionVoiceState?.self_mute ?? false; const isSelfMuted = VoiceStateCommands.getSelfMuteForConnection(connectionId);
const isSelfDeafened = connectionVoiceState?.self_deaf ?? false; const isSelfDeafened = connectionVoiceState?.self_deaf ?? false;
const isCameraOn = connectionVoiceState?.self_video ?? false; const isCameraOn = connectionVoiceState?.self_video ?? false;
const isStreaming = connectionVoiceState?.self_stream ?? false; const isStreaming = connectionVoiceState?.self_stream ?? false;
@@ -726,7 +726,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
} }
if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId)); if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId));
} else if (currentConnectionId && canUseCurrentConnectionAsRoot) { } else if (currentConnectionId && canUseCurrentConnectionAsRoot) {
const isSelfMuted = currentUserVoiceState?.self_mute ?? false; const isSelfMuted = VoiceStateCommands.getCurrentSelfMute();
const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false; const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false;
const isCameraOn = currentConnectionVoiceState?.self_video ?? false; const isCameraOn = currentConnectionVoiceState?.self_video ?? false;
const isStreaming = currentConnectionVoiceState?.self_stream ?? false; const isStreaming = currentConnectionVoiceState?.self_stream ?? false;
@@ -909,7 +909,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
const bulkActions: Array<MenuItemType> = []; const bulkActions: Array<MenuItemType> = [];
const connectionIds = userVoiceStates.map((u) => u.connectionId); const connectionIds = userVoiceStates.map((u) => u.connectionId);
if (isCurrentUser && isCurrentUserConnectedToVoice) { if (isCurrentUser && isCurrentUserConnectedToVoice) {
const allMuted = userVoiceStates.every(({voiceState}) => voiceState.self_mute); const allMuted = userVoiceStates.every(({connectionId}) =>
VoiceStateCommands.getSelfMuteForConnection(connectionId),
);
bulkActions.push({ bulkActions.push({
icon: ( icon: (
<SelfMuteIcon <SelfMuteIcon
@@ -35,10 +35,10 @@ interface SelfMuteMenuItemProps {
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer( export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
({connectionId, isDeviceSpecific = false, label}) => { ({connectionId, isDeviceSpecific = false, label}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const voiceState = connectionId const isSelfMuted =
? MediaEngine.getVoiceStateByConnectionId(connectionId) isDeviceSpecific && connectionId
: MediaEngine.getCurrentUserVoiceState(); ? VoiceStateCommands.getSelfMuteForConnection(connectionId)
const isSelfMuted = voiceState?.self_mute ?? false; : VoiceStateCommands.getCurrentSelfMute();
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (isDeviceSpecific && connectionId) { if (isDeviceSpecific && connectionId) {
VoiceStateCommands.toggleSelfMuteForConnection(connectionId); VoiceStateCommands.toggleSelfMuteForConnection(connectionId);
@@ -551,6 +551,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
isGuildDeafened, isGuildDeafened,
isPermissionMuted, isPermissionMuted,
isPushToTalkEffective, isPushToTalkEffective,
isSelfMuted: effectiveAudioState.effectiveMute || muteReason === 'self',
effectiveMuted, effectiveMuted,
canStream, canStream,
isCameraEnabled, isCameraEnabled,
@@ -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>): 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',
});
});
});
@@ -19,6 +19,7 @@ export interface VoiceControlBarSignals {
isGuildDeafened: boolean; isGuildDeafened: boolean;
isPermissionMuted: boolean; isPermissionMuted: boolean;
isPushToTalkEffective: boolean; isPushToTalkEffective: boolean;
isSelfMuted: boolean;
effectiveMuted: boolean; effectiveMuted: boolean;
canStream: boolean; canStream: boolean;
isCameraEnabled: boolean; isCameraEnabled: boolean;
@@ -54,6 +55,7 @@ const DEFAULT_SIGNALS: VoiceControlBarSignals = {
isGuildDeafened: false, isGuildDeafened: false,
isPermissionMuted: false, isPermissionMuted: false,
isPushToTalkEffective: false, isPushToTalkEffective: false,
isSelfMuted: false,
effectiveMuted: false, effectiveMuted: false,
canStream: true, canStream: true,
isCameraEnabled: false, isCameraEnabled: false,
@@ -74,7 +76,7 @@ function selectMuteLabel(signals: VoiceControlBarSignals): VoiceControlBarMuteLa
if (signals.isGuildDeafened) return 'moderatorDeafened'; if (signals.isGuildDeafened) return 'moderatorDeafened';
if (signals.isGuildMuted) return 'moderatorMuted'; if (signals.isGuildMuted) return 'moderatorMuted';
if (signals.isPermissionMuted) return 'permissionMuted'; if (signals.isPermissionMuted) return 'permissionMuted';
if (signals.isPushToTalkEffective) return 'pushToTalkHoldHint'; if (signals.isPushToTalkEffective && !signals.isSelfMuted) return 'pushToTalkHoldHint';
return signals.effectiveMuted ? 'unmute' : 'mute'; return signals.effectiveMuted ? 'unmute' : 'mute';
} }
@@ -97,11 +99,10 @@ function selectScreenShareLabel(signals: VoiceControlBarSignals): VoiceControlBa
function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState { function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState {
const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted; const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted;
const isMuteToggleLocked = isMicLocked || signals.isPushToTalkEffective;
return { return {
mute: { mute: {
disabled: isMuteToggleLocked, disabled: isMicLocked,
pressed: signals.effectiveMuted, pressed: signals.isPushToTalkEffective ? isMicLocked || signals.isSelfMuted : signals.effectiveMuted,
label: selectMuteLabel(signals), label: selectMuteLabel(signals),
}, },
deafen: { deafen: {
@@ -104,7 +104,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
useMediaEngineVersion(); useMediaEngineVersion();
const callHeaderState = useCallHeaderState(channel); const callHeaderState = useCallHeaderState(channel);
const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null); const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null);
const localSelfMute = LocalVoiceState.getSelfMute(); const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
const localSelfDeaf = LocalVoiceState.getSelfDeaf(); const localSelfDeaf = LocalVoiceState.getSelfDeaf();
const localSelfVideo = LocalVoiceState.getSelfVideo(); const localSelfVideo = LocalVoiceState.getSelfVideo();
const currentLatency = MediaEngine.currentLatency; const currentLatency = MediaEngine.currentLatency;
@@ -112,7 +112,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
const voiceServerEndpoint = MediaEngine.voiceServerEndpoint; const voiceServerEndpoint = MediaEngine.voiceServerEndpoint;
const connectionId = MediaEngine.connectionId; const connectionId = MediaEngine.connectionId;
const isConnected = MediaEngine.connected && MediaEngine.channelId === channel.id; 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 isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo; const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn); const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
@@ -85,7 +85,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
const connectedGuildId = MediaEngine.guildId; const connectedGuildId = MediaEngine.guildId;
const connectedChannelId = MediaEngine.channelId; const connectedChannelId = MediaEngine.channelId;
const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId); const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId);
const localSelfMute = LocalVoiceState.getSelfMute(); const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
const localSelfDeaf = LocalVoiceState.getSelfDeaf(); const localSelfDeaf = LocalVoiceState.getSelfDeaf();
const localSelfVideo = LocalVoiceState.getSelfVideo(); const localSelfVideo = LocalVoiceState.getSelfVideo();
const currentLatency = MediaEngine.currentLatency; const currentLatency = MediaEngine.currentLatency;
@@ -96,8 +96,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id); const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id);
const isGuildMuted = voiceState?.mute ?? false; const isGuildMuted = voiceState?.mute ?? false;
const isGuildDeafened = voiceState?.deaf ?? false; const isGuildDeafened = voiceState?.deaf ?? false;
const isMuted = const isMuted = localSelfMute || isGuildMuted || isGuildDeafened || isPermissionMuted;
(voiceState ? voiceState.self_mute : localSelfMute) || isGuildMuted || isGuildDeafened || isPermissionMuted;
const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened; const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened;
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo; const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn); const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
@@ -144,7 +144,7 @@ import voiceEngineV2AppScreenShareExecutionAdapter, {
type ScreenShareReconnectSnapshot, type ScreenShareReconnectSnapshot,
shouldRestoreScreenShareAfterReconnect, shouldRestoreScreenShareAfterReconnect,
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter'; } 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 {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
import {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter'; import {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter'; import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter';
@@ -1503,7 +1503,7 @@ class MediaEngineFacade extends Store {
private getEffectiveSelfMuteForVoiceStatePayload(): boolean { private getEffectiveSelfMuteForVoiceStatePayload(): boolean {
this.syncVoiceEngineV2AudioControlsFromAppState(); this.syncVoiceEngineV2AudioControlsFromAppState();
return selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot); return selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot);
} }
private cancelPendingServerDisconnect(): void { private cancelPendingServerDisconnect(): void {
@@ -1534,7 +1534,10 @@ class MediaEngineFacade extends Store {
raw.connection_id, raw.connection_id,
); );
LocalVoiceState.seedConnectionState(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(), selfDeaf: currentVoiceState?.self_deaf ?? LocalVoiceState.getSelfDeaf(),
selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(), selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(),
selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(), selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(),
@@ -2041,7 +2044,10 @@ class MediaEngineFacade extends Store {
selfStream: payload.self_stream, selfStream: payload.self_stream,
}); });
if (partial?.viewer_stream_keys !== undefined) { 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(); this.syncVoiceEngineV2AudioControlsFromAppState();
} }
handlePushToTalkModeChange(options: {preserveSelfMute?: boolean} = {}): void { handlePushToTalkModeChange(): void {
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange( voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(this.room, () => this.getCurrentUserVoiceState());
this.room,
() => this.getCurrentUserVoiceState(),
options,
);
this.syncVoiceEngineV2AudioControlsFromAppState(); this.syncVoiceEngineV2AudioControlsFromAppState();
} }
@@ -2387,11 +2389,9 @@ class MediaEngineFacade extends Store {
requestMicrophoneRefresh: (targetRoom, request) => requestMicrophoneRefresh: (targetRoom, request) =>
voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request), voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request),
configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom), configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom),
handleInputKeybindChange: (targetRoom, options) => handleInputKeybindChange: (targetRoom) =>
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange( voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(targetRoom, () =>
targetRoom, this.getCurrentUserVoiceState(),
() => this.getCurrentUserVoiceState(),
options,
), ),
applyAllLocalAudioPreferences: () => voiceEngineV2AppMediaExecutionAdapter.applyAllLocalAudioPreferences(room), applyAllLocalAudioPreferences: () => voiceEngineV2AppMediaExecutionAdapter.applyAllLocalAudioPreferences(room),
}, },
@@ -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);
});
});
@@ -16,7 +16,7 @@ import {
type VoiceMediaEngineVoiceState, type VoiceMediaEngineVoiceState,
} from '@app/features/voice/engine/VoiceMediaEngineBridge'; } from '@app/features/voice/engine/VoiceMediaEngineBridge';
import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes'; 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 {isChannelP2p} from '@app/features/voice/state/ChannelP2pStatus';
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState'; import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent'; import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent';
@@ -180,7 +180,7 @@ export function sendVoiceStateConnect(
LocalVoiceState.ensurePermissionMute(); LocalVoiceState.ensurePermissionMute();
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine(); const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
const effectiveSelfMute = snapshot const effectiveSelfMute = snapshot
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot) ? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
: LocalVoiceState.getSelfMute(); : LocalVoiceState.getSelfMute();
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute); const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute);
const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null; const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null;
@@ -227,7 +227,7 @@ export function syncVoiceStateToServer(
if (!socket) return; if (!socket) return;
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine(); const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
const effectiveSelfMute = snapshot const effectiveSelfMute = snapshot
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot) ? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
: LocalVoiceState.getSelfMute(); : LocalVoiceState.getSelfMute();
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute); const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute);
socket.updateVoiceStateExplicit({ socket.updateVoiceStateExplicit({
@@ -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>): 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);
});
});
@@ -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();
});
});
@@ -23,14 +23,13 @@ interface VoiceEngineV2AppAudioPreferencesSnapshot {
readonly participantMutes: Readonly<Record<string, boolean>>; readonly participantMutes: Readonly<Record<string, boolean>>;
readonly connectionVolumes: Readonly<Record<string, Readonly<Record<string, number>>>>; readonly connectionVolumes: Readonly<Record<string, Readonly<Record<string, number>>>>;
readonly streamAudioRevision: number; readonly streamAudioRevision: number;
readonly transmitMode: typeof Keybind.transmitMode;
readonly pushToMuteActive: boolean; readonly pushToMuteActive: boolean;
} }
export interface VoiceEngineV2AppAudioPreferencesMediaAdapter { export interface VoiceEngineV2AppAudioPreferencesMediaAdapter {
requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise<void>; requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise<void>;
configureVoiceInput(room: Room): void; configureVoiceInput(room: Room): void;
handleInputKeybindChange(room: Room, options: {preserveSelfMute: boolean}): void; handleInputKeybindChange(room: Room): void;
applyAllLocalAudioPreferences(room: Room): void; applyAllLocalAudioPreferences(room: Room): void;
} }
@@ -50,7 +49,6 @@ function createVoiceEngineV2AppAudioPreferencesSnapshot(): VoiceEngineV2AppAudio
participantMutes: ParticipantVolume.localMutes, participantMutes: ParticipantVolume.localMutes,
connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId, connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId,
streamAudioRevision: StreamAudioPrefs.audioPrefsRevision, streamAudioRevision: StreamAudioPrefs.audioPrefsRevision,
transmitMode: Keybind.transmitMode,
pushToMuteActive: Keybind.isPushToMuteEffective(), pushToMuteActive: Keybind.isPushToMuteEffective(),
}; };
} }
@@ -97,7 +95,7 @@ function syncVoiceEngineV2AppAudioPreferences(
); );
assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn'); assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn');
if (previous.pushToMuteActive !== current.pushToMuteActive) { if (previous.pushToMuteActive !== current.pushToMuteActive) {
adapter.handleInputKeybindChange(room, {preserveSelfMute: previous.transmitMode === current.transmitMode}); adapter.handleInputKeybindChange(room);
} }
const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings); const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings);
if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) { if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) {
@@ -540,15 +540,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
await this.enforceSpeakPermissionMute(room); await this.enforceSpeakPermissionMute(room);
return; return;
} }
const pttActive = isPushToTalkActiveFromAppState(); if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) {
const ptmActive = isPushToMuteActiveFromAppState();
const effectiveMute =
audioState.effectiveDeaf ||
audioState.serverMute ||
audioState.selfMute ||
(pttActive && !Keybind.pushToTalkHeld) ||
(ptmActive && Keybind.pushToMuteHeld);
if (effectiveMute) {
this.setAudioPublicationsMuted(room, true, 'voice state update'); this.setAudioPublicationsMuted(room, true, 'voice state update');
this.syncLocalSpeakingOverride(room); this.syncLocalSpeakingOverride(room);
return; return;
@@ -1506,7 +1498,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
serverDeaf: serverVoiceState?.deaf, serverDeaf: serverVoiceState?.deaf,
}); });
if (isMutedOrDeafened(audioState)) return; if (isMutedOrDeafened(audioState)) return;
const targetMute = audioState.selfMute || !held; const targetMute = getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls();
const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk'); const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk');
if (!ok) { if (!ok) {
this.syncVoiceState({self_mute: true}); this.syncVoiceState({self_mute: true});
@@ -1545,11 +1537,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
this.syncLocalSpeakingOverride(room); this.syncLocalSpeakingOverride(room);
} }
handlePushToTalkModeChange( handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
room: Room | null,
getCurrentUserVoiceState: () => VoiceState | null,
options: {preserveSelfMute?: boolean} = {},
): void {
assertNullableObjectLike<Room>(room, 'handlePushToTalkModeChange.room'); assertNullableObjectLike<Room>(room, 'handlePushToTalkModeChange.room');
assert.equal( assert.equal(
typeof getCurrentUserVoiceState, typeof getCurrentUserVoiceState,
@@ -1558,9 +1546,6 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
); );
Keybind.resetPushToTalkState(); Keybind.resetPushToTalkState();
Keybind.resetPushToMuteState(); Keybind.resetPushToMuteState();
if (!options.preserveSelfMute && isPushToTalkActiveFromAppState() && LocalVoiceState.getSelfMute()) {
LocalVoiceState.updateSelfMute(false);
}
const serverVoiceState = getCurrentUserVoiceState(); const serverVoiceState = getCurrentUserVoiceState();
const audioState = this.getEffectiveAudioState({ const audioState = this.getEffectiveAudioState({
serverMute: serverVoiceState?.mute ?? false, serverMute: serverVoiceState?.mute ?? false,
@@ -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<unknown>) =>
setVoiceEngineV2ParticipantAudioLevelSpeaking(...(args as [])),
syncLocalVoiceStateWithServer: (...args: Array<unknown>) => 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<typeof vi.fn>;
unmute: ReturnType<typeof vi.fn>;
}
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<string, FakeMicrophonePublication>} {
const publications = new Map<string, FakeMicrophonePublication>();
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();
});
});
@@ -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>): 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);
});
});
@@ -211,11 +211,15 @@ export function selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload(
return selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio); return selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio);
} }
export function selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload( export function selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
source: VoiceEngineV2AppProjectionSource, source: VoiceEngineV2AppProjectionSource,
): boolean { ): boolean {
assertNonNullObject(source, 'source'); assertNonNullObject(source, 'source');
return selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio); const audio = selectVoiceEngineV2AppLocalMedia(source).audio;
return (
selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio) ||
selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(audio)
);
} }
function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean { function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean {
@@ -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
}
}
@@ -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 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 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 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 {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer';
import { import {
availableVoiceEngineV2Capabilities, availableVoiceEngineV2Capabilities,
@@ -49,6 +50,7 @@ interface LocalMediaFixture {
const fixtures = [ const fixtures = [
localMediaFixtureJson, localMediaFixtureJson,
pttPtmFixtureJson, pttPtmFixtureJson,
pttSelfMuteFixtureJson,
deviceHotswapFixtureJson, deviceHotswapFixtureJson,
permissionsRecoveryFixtureJson, permissionsRecoveryFixtureJson,
microphoneFailureRecoveryFixtureJson, microphoneFailureRecoveryFixtureJson,