mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
740 lines
31 KiB
TypeScript
740 lines
31 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
|
import {SettingsTabSection} from '@app/features/app/components/dialogs/shared/SettingsTabLayout';
|
|
import {DESKTOP_DOWNLOAD_URL} from '@app/features/app/config/I18nDisplayConstants';
|
|
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
|
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
|
import {KeybindRecorder} from '@app/features/input/components/KeybindRecorder';
|
|
import Keybind, {getDefaultKeybind} from '@app/features/input/state/InputKeybind';
|
|
import {openMacPermissionsModal} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
|
import {macPermissionNameDescriptor} from '@app/features/permissions/system/components/useMacPermissionControl';
|
|
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
|
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
|
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
|
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
|
|
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
|
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
|
import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting';
|
|
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
|
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
|
import {EntranceSoundSection} from '@app/features/user/components/modals/tabs/components/EntranceSoundSection';
|
|
import {MicTestSection} from '@app/features/user/components/modals/tabs/components/MicTestSection';
|
|
import {SystemShortcutsPushToTalkAlert} from '@app/features/user/components/modals/tabs/components/SystemShortcutsSection';
|
|
import {useMediaPermission} from '@app/features/user/components/modals/tabs/hooks/useMediaPermission';
|
|
import {SystemShortcutRowHint} from '@app/features/user/components/modals/tabs/keybinds_tab/SystemShortcutRowHint';
|
|
import styles from '@app/features/user/components/modals/tabs/UserVoiceTab.module.css';
|
|
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
|
import {supportsVoiceOutputDeviceSelection} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
|
import type VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
|
import {
|
|
type ExternalAudioProcessorMatch,
|
|
findExternalProcessorForDevice,
|
|
} from '@app/features/voice/utils/ExternalAudioProcessor';
|
|
import {prefetchDeepFilterAssets} from '@app/features/voice/utils/noise_suppression/DeepFilter';
|
|
import type {VoiceNoiseSuppressionBackend} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionBackends';
|
|
import {
|
|
getNoiseSuppressionChoiceValues,
|
|
getSelectedNoiseSuppressionChoice,
|
|
isStereoMicrophoneChoiceAvailable,
|
|
isStereoMicrophoneEnabled,
|
|
setNoiseSuppressionChoice,
|
|
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionChoices';
|
|
import {
|
|
getNoiseSuppressionChoiceLabel,
|
|
getNoiseSuppressionFallbackMessage,
|
|
STEREO_MICROPHONE_DESCRIPTION_DESCRIPTOR,
|
|
STEREO_MICROPHONE_DESCRIPTOR,
|
|
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionLabels';
|
|
import {buildSettingsDeviceOptions} from '@app/features/voice/utils/SettingsDeviceOptions';
|
|
import {hasDeviceLabels, resolveEffectiveDeviceId} from '@app/features/voice/utils/VoiceDeviceManager';
|
|
import {
|
|
VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR,
|
|
VOICE_DIRECT_INPUT_PROFILE_DESCRIPTOR,
|
|
VOICE_ECHO_CANCELLATION_DESCRIPTOR,
|
|
VOICE_FOCUSED_VOICE_PROFILE_DESCRIPTOR,
|
|
VOICE_INPUT_DEVICE_DESCRIPTOR,
|
|
VOICE_INPUT_VOLUME_DESCRIPTOR,
|
|
VOICE_NOISE_SUPPRESSION_DESCRIPTOR,
|
|
VOICE_OUTPUT_DEVICE_DESCRIPTOR,
|
|
VOICE_OUTPUT_VOLUME_DESCRIPTOR,
|
|
} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
|
import type {VoiceProcessingMode} from '@app/features/voice/utils/VoiceProcessingProfile';
|
|
import {VOICE_VOLUME_MAX_PERCENT} from '@app/features/voice/utils/VoiceVolumeUtils';
|
|
import {msg, ph} from '@lingui/core/macro';
|
|
import {Trans, useLingui} from '@lingui/react/macro';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useCallback, useEffect, useMemo} from 'react';
|
|
|
|
const RELEASE_DELAY_DESCRIPTOR = msg({
|
|
message: 'Release delay',
|
|
comment: 'Short label for push-to-talk release delay.',
|
|
});
|
|
const RELEASE_DELAY_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Keep transmitting briefly after release.',
|
|
comment: 'Description for push-to-talk release delay.',
|
|
});
|
|
const FOCUSED_VOICE_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Recommended. Cleans up your mic for clear speech.',
|
|
comment: 'Description for the focused voice profile option in the voice tab.',
|
|
});
|
|
const DIRECT_INPUT_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: "Sends your audio untouched. Best if you're using external audio software.",
|
|
comment: 'Description for the direct input profile option in the voice tab.',
|
|
});
|
|
const CUSTOM_DESCRIPTOR = msg({
|
|
message: 'Custom',
|
|
context: 'voice-processing-profile',
|
|
comment: 'Voice input processing profile where the user configures processing manually.',
|
|
});
|
|
const CUSTOM_PROFILE_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Adjust each setting yourself: noise suppression, echo cancellation, and gain.',
|
|
comment: 'Description for the custom profile option in the voice tab.',
|
|
});
|
|
const PUSH_TO_TALK_LIMITED_DESCRIPTOR = msg({
|
|
message: 'Push-to-talk (limited)',
|
|
comment: 'Short label in the voice tab. Keep it concise.',
|
|
});
|
|
const DOWNLOAD_DESKTOP_APP_DESCRIPTOR = msg({
|
|
message: 'Download desktop app',
|
|
comment: 'Button or menu action label in the voice tab. Keep it concise.',
|
|
});
|
|
const I_UNDERSTAND_DESCRIPTOR = msg({
|
|
message: 'I understand',
|
|
comment: 'Short label in the voice tab. Keep it concise.',
|
|
});
|
|
const PUSH_TO_TALK_DESCRIPTOR = msg({
|
|
message: 'Push-to-talk',
|
|
comment: 'Switch label in the voice tab toggling push-to-talk mode. Keep it concise.',
|
|
});
|
|
const AUTO_SET_ACTIVITY_THRESHOLD_DESCRIPTOR = msg({
|
|
message: 'Auto-set activity threshold',
|
|
comment: 'Short label in the voice tab. Keep it concise.',
|
|
});
|
|
const SELECT_VOICE_PROCESSING_DESCRIPTOR = msg({
|
|
message: 'Select voice processing mode',
|
|
comment: 'Aria label for the voice processing radio group in the voice settings tab. Keep it concise.',
|
|
});
|
|
const ENABLE_PLATFORM_PERMISSION_DESCRIPTOR = msg({
|
|
message: 'Enable {permissionName} permission',
|
|
comment: 'Voice settings CTA label for opening a platform permission prompt.',
|
|
});
|
|
const PUSH_TO_TALK_BROWSER_LIMITED_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Push-to-talk only works while the browser tab is focused.',
|
|
comment: 'Voice settings warning shown when push-to-talk is enabled in a browser.',
|
|
});
|
|
const PUSH_TO_TALK_PERMISSION_LIMITED_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Push-to-talk only works while {productName} is focused until {permissionName} is enabled.',
|
|
comment:
|
|
'Voice settings warning shown when macOS Input Monitoring is required for system-wide push-to-talk. {productName} is the app name and {permissionName} is the macOS permission name.',
|
|
});
|
|
const PRODUCT_NEEDS_MICROPHONE_ACCESS_DESCRIPTOR = msg({
|
|
message: '{productName} needs microphone access to list your devices.',
|
|
comment: 'Voice settings notice shown when microphone permission has not been granted.',
|
|
});
|
|
const PUSH_TO_TALK_RELEASE_DELAY_OPTIONS = [20, 100, 250, 500, 1000, 1500, 2000] as const;
|
|
|
|
const getNearestPushToTalkReleaseDelay = (value: number): number => {
|
|
let nearest: number = PUSH_TO_TALK_RELEASE_DELAY_OPTIONS[0];
|
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
for (const option of PUSH_TO_TALK_RELEASE_DELAY_OPTIONS) {
|
|
const distance = Math.abs(option - value);
|
|
if (distance < nearestDistance) {
|
|
nearest = option;
|
|
nearestDistance = distance;
|
|
}
|
|
}
|
|
return nearest;
|
|
};
|
|
|
|
const resolvePushToTalkReleaseDelayInput = (
|
|
inputValue: string,
|
|
options: ReadonlyArray<ComboboxOption<number>>,
|
|
): number | undefined => {
|
|
const normalized = inputValue.trim().toLowerCase();
|
|
const numericMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (!numericMatch) return undefined;
|
|
const parsedValue = Number(numericMatch[1]);
|
|
if (!Number.isFinite(parsedValue)) return undefined;
|
|
const unitMatch = normalized.match(/[0-9.]\s*([a-z]+)/);
|
|
const unit = unitMatch?.[1] ?? 'ms';
|
|
const valueInMs = unit.startsWith('s') ? parsedValue * 1000 : parsedValue;
|
|
return options.reduce((nearest, option) =>
|
|
Math.abs(option.value - valueInMs) < Math.abs(nearest.value - valueInMs) ? option : nearest,
|
|
).value;
|
|
};
|
|
const AUTO_GAIN_DESCRIPTION_DESCRIPTOR = msg({
|
|
message: 'Evens out your mic volume so you are not too quiet.',
|
|
comment: 'Description for the automatic gain control toggle in the voice processing settings.',
|
|
});
|
|
const INPUT_AND_OUTPUT_DESCRIPTOR = msg({
|
|
message: 'Input and output',
|
|
comment: 'Subsection title in the voice tab. Keep it concise.',
|
|
});
|
|
const VOLUME_DESCRIPTOR = msg({
|
|
message: 'Volume',
|
|
comment: 'Subsection title in the voice tab. Keep it concise.',
|
|
});
|
|
const MIC_TEST_DESCRIPTOR = msg({
|
|
message: 'Mic test',
|
|
comment: 'Subsection title in the voice tab. Keep it concise.',
|
|
});
|
|
const ENTRANCE_SOUND_DESCRIPTOR = msg({
|
|
message: 'Entrance sound',
|
|
comment: 'Subsection title in the voice tab. Keep it concise.',
|
|
});
|
|
|
|
interface VoiceTabProps {
|
|
voiceSettings: typeof VoiceSettings;
|
|
hasPremium: boolean;
|
|
autoRequestPermission?: boolean;
|
|
}
|
|
|
|
export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoRequestPermission = false}) => {
|
|
const {i18n} = useLingui();
|
|
const formatPercentage = useCallback(
|
|
(value: number) => formatRoundedPercentage(i18n.locale, value),
|
|
[i18n, i18n.locale],
|
|
);
|
|
const {
|
|
inputDeviceId,
|
|
outputDeviceId,
|
|
inputVolume,
|
|
outputVolume,
|
|
echoCancellation,
|
|
autoGainControl,
|
|
vadThreshold,
|
|
vadAutoSensitivity,
|
|
} = voiceSettings;
|
|
const {
|
|
devices,
|
|
deviceState,
|
|
status: permissionStatus,
|
|
requestPermission,
|
|
} = useMediaPermission('audio', {
|
|
autoRequest: autoRequestPermission,
|
|
});
|
|
const isNativeDesktop = NativePermission.isDesktop;
|
|
const isNativeMac = NativePermission.isMacOS;
|
|
const inputMonitoringGranted = NativePermission.isInputMonitoringGranted;
|
|
const inputDevices = useMemo(() => devices.filter((d) => d.kind === 'audioinput'), [devices]);
|
|
const outputDevices = useMemo(() => devices.filter((d) => d.kind === 'audiooutput'), [devices]);
|
|
const transmitMode = Keybind.transmitMode;
|
|
const isPushToTalk = transmitMode === 'voice_push_to_talk';
|
|
const pttPrimary = Keybind.getPrimaryCustomKeybind('voice_push_to_talk');
|
|
const pttCombo = pttPrimary?.combo ?? {key: '', enabled: true, global: true};
|
|
const pttReleaseDelay = Keybind.pushToTalkReleaseDelay;
|
|
const selectedPttReleaseDelay = getNearestPushToTalkReleaseDelay(pttReleaseDelay);
|
|
const pttReleaseDelayOptions: ReadonlyArray<ComboboxOption<number>> = useMemo(() => {
|
|
const formatter = getCachedNumberFormat(i18n.locale, {
|
|
style: 'unit',
|
|
unit: 'millisecond',
|
|
unitDisplay: 'narrow',
|
|
maximumFractionDigits: 0,
|
|
});
|
|
return PUSH_TO_TALK_RELEASE_DELAY_OPTIONS.map((value) => ({value, label: formatter.format(value)}));
|
|
}, [i18n.locale]);
|
|
const isPttLimited = !isNativeDesktop || (isNativeMac && !inputMonitoringGranted);
|
|
const defaultPttCombo = getDefaultKeybind('voice_push_to_talk', i18n);
|
|
const inputHasLabels = hasDeviceLabels(inputDevices);
|
|
const effectiveInputDeviceId = resolveEffectiveDeviceId(inputDeviceId, inputDevices) ?? 'default';
|
|
const canSelectOutputDevice = supportsVoiceOutputDeviceSelection();
|
|
const effectiveOutputDeviceId = canSelectOutputDevice
|
|
? (resolveEffectiveDeviceId(outputDeviceId, outputDevices) ?? 'default')
|
|
: 'default';
|
|
const activeInputDevice = inputDevices.find((d) => d.deviceId === effectiveInputDeviceId) ?? null;
|
|
const activeInputLabel = activeInputDevice?.label || null;
|
|
const voiceProcessingMode = voiceSettings.getVoiceProcessingModeForDeviceLabel(activeInputLabel);
|
|
const externalProcessor: ExternalAudioProcessorMatch | null = inputHasLabels
|
|
? findExternalProcessorForDevice(effectiveInputDeviceId, inputDevices)
|
|
: null;
|
|
const showExternalProcessorHint = externalProcessor !== null && voiceProcessingMode !== 'studio';
|
|
useEffect(() => {
|
|
if (pttReleaseDelay !== selectedPttReleaseDelay) Keybind.setPushToTalkReleaseDelay(selectedPttReleaseDelay);
|
|
}, [pttReleaseDelay, selectedPttReleaseDelay]);
|
|
useEffect(() => {
|
|
prefetchDeepFilterAssets();
|
|
}, [voiceProcessingMode]);
|
|
const handleInputDeviceChange = (value: string) => {
|
|
VoiceSettingsCommands.update({inputDeviceId: value});
|
|
};
|
|
const handleVoiceProcessingModeChange = (mode: VoiceProcessingMode) => {
|
|
VoiceSettingsCommands.setActiveInputVoiceProcessingMode(mode);
|
|
};
|
|
const inputDeviceOptions = useMemo(
|
|
() => buildSettingsDeviceOptions(deviceState, 'audioinput', i18n),
|
|
[deviceState, i18n.locale],
|
|
);
|
|
const outputDeviceOptions = useMemo(
|
|
() =>
|
|
buildSettingsDeviceOptions(
|
|
canSelectOutputDevice ? deviceState : {...deviceState, outputDevices: []},
|
|
'audiooutput',
|
|
i18n,
|
|
),
|
|
[canSelectOutputDevice, deviceState, i18n.locale],
|
|
);
|
|
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
|
const profileOptions: Array<RadioOption<VoiceProcessingMode>> = [
|
|
{
|
|
value: 'voice',
|
|
name: i18n._(VOICE_FOCUSED_VOICE_PROFILE_DESCRIPTOR),
|
|
desc: i18n._(FOCUSED_VOICE_DESCRIPTION_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'studio',
|
|
name: i18n._(VOICE_DIRECT_INPUT_PROFILE_DESCRIPTOR),
|
|
desc: i18n._(DIRECT_INPUT_DESCRIPTION_DESCRIPTOR),
|
|
},
|
|
{
|
|
value: 'custom',
|
|
name: i18n._(CUSTOM_DESCRIPTOR),
|
|
desc: i18n._(CUSTOM_PROFILE_DESCRIPTION_DESCRIPTOR),
|
|
},
|
|
];
|
|
const noiseSuppressionChoice = getSelectedNoiseSuppressionChoice();
|
|
const noiseSuppressionFallbackMessage = getNoiseSuppressionFallbackMessage(i18n);
|
|
const noiseSuppressionOptions: Array<ComboboxOption<VoiceNoiseSuppressionBackend>> =
|
|
getNoiseSuppressionChoiceValues().map((backend) => ({
|
|
value: backend,
|
|
label: getNoiseSuppressionChoiceLabel(i18n, backend),
|
|
}));
|
|
const stereoMicrophoneAvailable = isStereoMicrophoneChoiceAvailable(activeInputLabel);
|
|
const setPushToTalkEnabled = (enabled: boolean) => {
|
|
const mode = enabled ? 'voice_push_to_talk' : 'voice_activity';
|
|
if (enabled && !isNativeDesktop) {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<ConfirmModal
|
|
title={i18n._(PUSH_TO_TALK_LIMITED_DESCRIPTOR)}
|
|
description={
|
|
<p data-flx="user.voice-tab.set-push-to-talk-enabled.p">
|
|
<Trans>
|
|
In a browser, push-to-talk only works while the {ph({PRODUCT_NAME: RuntimeConfig.productName})} tab is
|
|
focused. Install the desktop app for system-wide push-to-talk.
|
|
</Trans>
|
|
</p>
|
|
}
|
|
primaryText={i18n._(DOWNLOAD_DESKTOP_APP_DESCRIPTOR)}
|
|
primaryVariant="primary"
|
|
secondaryText={i18n._(I_UNDERSTAND_DESCRIPTOR)}
|
|
onPrimary={() => {
|
|
void openExternalUrl(DESKTOP_DOWNLOAD_URL);
|
|
}}
|
|
onSecondary={() => {
|
|
Keybind.setTransmitMode(mode);
|
|
MediaEngine.handlePushToTalkModeChange();
|
|
}}
|
|
data-flx="user.voice-tab.set-push-to-talk-enabled.confirm-modal"
|
|
/>
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
if (enabled && isNativeMac && !inputMonitoringGranted) {
|
|
openMacPermissionsModal('input-monitoring');
|
|
}
|
|
Keybind.setTransmitMode(mode);
|
|
MediaEngine.handlePushToTalkModeChange();
|
|
};
|
|
const handleOpenInputMonitoringModal = () => {
|
|
openMacPermissionsModal('input-monitoring');
|
|
};
|
|
const pttAvailable = voiceProcessingMode !== 'studio';
|
|
const showPttDetails = pttAvailable && isPushToTalk;
|
|
const renderPttControls = () => (
|
|
<>
|
|
<Switch
|
|
label={<Trans>Push-to-talk</Trans>}
|
|
value={isPushToTalk}
|
|
onChange={setPushToTalkEnabled}
|
|
ariaLabel={i18n._(PUSH_TO_TALK_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.render-ptt-controls.switch.set-push-to-talk-enabled"
|
|
/>
|
|
{showPttDetails && (
|
|
<div className={styles.pttSettings} data-flx="user.voice-tab.render-ptt-controls.ptt-settings">
|
|
<p className={styles.pttSettingDescription} data-flx="user.voice-tab.render-ptt-controls.ptt-mute-note">
|
|
<Trans>
|
|
While push-to-talk is on, your shortcut controls your microphone, so the mute button is disabled.
|
|
</Trans>
|
|
</p>
|
|
<div className={styles.pttSettingRow} data-flx="user.voice-tab.render-ptt-controls.ptt-setting-row">
|
|
<KeybindRecorder
|
|
label={<Trans>Shortcut</Trans>}
|
|
labelPlacement="inline"
|
|
action="voice_push_to_talk"
|
|
value={pttCombo}
|
|
defaultValue={defaultPttCombo}
|
|
onChange={(combo) => {
|
|
Keybind.setPrimaryCustomKeybindCombo('voice_push_to_talk', {
|
|
...combo,
|
|
global: pttCombo.global ?? true,
|
|
});
|
|
}}
|
|
onReset={() => {
|
|
if (defaultPttCombo) {
|
|
Keybind.setPrimaryCustomKeybindCombo('voice_push_to_talk', {
|
|
...defaultPttCombo,
|
|
global: pttCombo.global ?? true,
|
|
});
|
|
}
|
|
}}
|
|
data-flx="user.voice-tab.render-ptt-controls.keybind-recorder.set-primary-custom-keybind-combo"
|
|
/>
|
|
<SystemShortcutRowHint
|
|
action="voice_push_to_talk"
|
|
variant="voice-tab"
|
|
data-flx="user.voice-tab.render-ptt-controls.system-shortcut-row-hint"
|
|
/>
|
|
</div>
|
|
<div className={styles.pttSettingRow} data-flx="user.voice-tab.render-ptt-controls.ptt-setting-row--2">
|
|
<CompactComboboxRow<number>
|
|
label={i18n._(RELEASE_DELAY_DESCRIPTOR)}
|
|
description={i18n._(RELEASE_DELAY_DESCRIPTION_DESCRIPTOR)}
|
|
value={selectedPttReleaseDelay}
|
|
options={pttReleaseDelayOptions}
|
|
onChange={(value) => Keybind.setPushToTalkReleaseDelay(value)}
|
|
autoSelectValueFromInput={resolvePushToTalkReleaseDelayInput}
|
|
controlWidth="small"
|
|
dataFlx="user.voice-tab.render-ptt-controls.select.release-delay"
|
|
data-flx="user.user-voice-tab.render-ptt-controls.compact-combobox-row.set-push-to-talk-release-delay"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isPushToTalk && isPttLimited && (
|
|
<WarningAlert
|
|
actions={
|
|
!isNativeDesktop ? (
|
|
<Button
|
|
variant="primary"
|
|
small={true}
|
|
onClick={() => void openExternalUrl(DESKTOP_DOWNLOAD_URL)}
|
|
data-flx="user.voice-tab.render-ptt-controls.button.download-desktop-app"
|
|
>
|
|
{i18n._(DOWNLOAD_DESKTOP_APP_DESCRIPTOR)}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="primary"
|
|
small={true}
|
|
onClick={handleOpenInputMonitoringModal}
|
|
data-flx="user.voice-tab.render-ptt-controls.button.open-input-monitoring-modal"
|
|
>
|
|
{i18n._(ENABLE_PLATFORM_PERMISSION_DESCRIPTOR, {
|
|
permissionName: i18n._(macPermissionNameDescriptor('input-monitoring')),
|
|
})}
|
|
</Button>
|
|
)
|
|
}
|
|
data-flx="user.voice-tab.render-ptt-controls.warning-alert"
|
|
>
|
|
{!isNativeDesktop
|
|
? i18n._(PUSH_TO_TALK_BROWSER_LIMITED_DESCRIPTION_DESCRIPTOR)
|
|
: i18n._(PUSH_TO_TALK_PERMISSION_LIMITED_DESCRIPTION_DESCRIPTOR, {
|
|
productName: RuntimeConfig.productName,
|
|
permissionName: i18n._(macPermissionNameDescriptor('input-monitoring')),
|
|
})}
|
|
</WarningAlert>
|
|
)}
|
|
{isPushToTalk && !isPttLimited && (
|
|
<SystemShortcutsPushToTalkAlert data-flx="user.voice-tab.render-ptt-controls.system-shortcuts-ptt-alert" />
|
|
)}
|
|
</>
|
|
);
|
|
const renderAutoGainControlSwitch = () => (
|
|
<Switch
|
|
label={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
|
|
description={i18n._(AUTO_GAIN_DESCRIPTION_DESCRIPTOR)}
|
|
value={autoGainControl}
|
|
onChange={(value) => VoiceSettingsCommands.update({autoGainControl: value})}
|
|
ariaLabel={i18n._(VOICE_AUTOMATIC_GAIN_CONTROL_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.render-auto-gain-control-switch.switch.update-auto-gain-control"
|
|
/>
|
|
);
|
|
const renderStereoMicrophoneSwitch = (dataFlx: string) =>
|
|
stereoMicrophoneAvailable && (
|
|
<Switch
|
|
label={i18n._(STEREO_MICROPHONE_DESCRIPTOR)}
|
|
description={i18n._(STEREO_MICROPHONE_DESCRIPTION_DESCRIPTOR)}
|
|
value={isStereoMicrophoneEnabled()}
|
|
onChange={(value) => VoiceSettingsCommands.update({stereoMicrophone: value})}
|
|
ariaLabel={i18n._(STEREO_MICROPHONE_DESCRIPTOR)}
|
|
data-flx={dataFlx}
|
|
/>
|
|
);
|
|
const renderCustomProfile = () => (
|
|
<div className={styles.profileSubSection} data-flx="user.voice-tab.render-custom-profile.profile-sub-section">
|
|
{renderPttControls()}
|
|
<Switch
|
|
label={<Trans>Auto-set activity threshold</Trans>}
|
|
value={vadAutoSensitivity}
|
|
onChange={(value) => VoiceSettingsCommands.update({vadAutoSensitivity: value})}
|
|
ariaLabel={i18n._(AUTO_SET_ACTIVITY_THRESHOLD_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.render-custom-profile.switch.update"
|
|
/>
|
|
{!vadAutoSensitivity && (
|
|
<div
|
|
className={styles.sensitivitySliderWrapper}
|
|
data-flx="user.voice-tab.render-custom-profile.sensitivity-slider-wrapper"
|
|
>
|
|
<div className={styles.sliderLabelRow} data-flx="user.voice-tab.render-custom-profile.slider-label-row">
|
|
<div className={styles.sliderLabel} data-flx="user.voice-tab.render-custom-profile.slider-label">
|
|
<Trans>Activity threshold</Trans>
|
|
</div>
|
|
<SliderResetIconButton
|
|
canReset={canResetSliderValue(vadThreshold, 50)}
|
|
onReset={() => VoiceSettingsCommands.update({vadThreshold: 50})}
|
|
ariaLabel={resetSliderLabel}
|
|
dataFlx="user.voice-tab.render-custom-profile.reset-button.vad-threshold"
|
|
data-flx="user.user-voice-tab.render-custom-profile.slider-reset-icon-button"
|
|
/>
|
|
</div>
|
|
<Slider
|
|
value={vadThreshold}
|
|
defaultValue={vadThreshold}
|
|
factoryDefaultValue={50}
|
|
minValue={0}
|
|
maxValue={100}
|
|
step={1}
|
|
onValueRender={formatPercentage}
|
|
asValueChanges={(value) => VoiceSettingsCommands.update({vadThreshold: value})}
|
|
onValueChange={(value) => VoiceSettingsCommands.update({vadThreshold: value})}
|
|
data-flx="user.voice-tab.render-custom-profile.slider"
|
|
/>
|
|
</div>
|
|
)}
|
|
<CompactComboboxRow<VoiceNoiseSuppressionBackend>
|
|
label={i18n._(VOICE_NOISE_SUPPRESSION_DESCRIPTOR)}
|
|
description={noiseSuppressionFallbackMessage}
|
|
value={noiseSuppressionChoice}
|
|
options={noiseSuppressionOptions}
|
|
onChange={setNoiseSuppressionChoice}
|
|
isSearchable={false}
|
|
controlWidth="medium"
|
|
dataFlx="user.voice-tab.render-custom-profile.select.set-noise-suppression-method"
|
|
data-flx="user.user-voice-tab.render-custom-profile.compact-combobox-row.set-noise-suppression-method"
|
|
/>
|
|
{renderStereoMicrophoneSwitch('user.voice-tab.render-custom-profile.switch.set-stereo-microphone')}
|
|
<Switch
|
|
label={i18n._(VOICE_ECHO_CANCELLATION_DESCRIPTOR)}
|
|
value={echoCancellation}
|
|
onChange={(value) => VoiceSettingsCommands.update({echoCancellation: value})}
|
|
ariaLabel={i18n._(VOICE_ECHO_CANCELLATION_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.render-custom-profile.switch.update--2"
|
|
/>
|
|
{renderAutoGainControlSwitch()}
|
|
</div>
|
|
);
|
|
return (
|
|
<>
|
|
<SettingsTabSection title={i18n._(INPUT_AND_OUTPUT_DESCRIPTOR)} data-flx="user.voice-tab.devices-section">
|
|
{inputDevices.length === 0 && permissionStatus !== 'loading' ? (
|
|
<div className={styles.deviceNotice} data-flx="user.voice-tab.device-notice">
|
|
<div className={styles.deviceNoticeText} data-flx="user.voice-tab.device-notice-text">
|
|
<div className={styles.deviceNoticeTitle} data-flx="user.voice-tab.device-notice-title">
|
|
<Trans>No microphone found</Trans>
|
|
</div>
|
|
<p className={styles.deviceNoticeDescription} data-flx="user.voice-tab.device-notice-description">
|
|
{permissionStatus === 'denied' ? (
|
|
isNativeDesktop ? (
|
|
<Trans comment="Shown in voice settings on the macOS app when microphone access is denied. {ph({PRODUCT_NAME: RuntimeConfig.productName})} is the app name. Keep 'System Settings', 'Privacy & Security' and the permission name as macOS shows them in this language.">
|
|
Allow {ph({PRODUCT_NAME: RuntimeConfig.productName})} to access your microphone in System Settings
|
|
→ Privacy & Security → Microphone.
|
|
</Trans>
|
|
) : (
|
|
<Trans>
|
|
Allow {ph({PRODUCT_NAME: RuntimeConfig.productName})} to access your microphone. Check your
|
|
browser's address bar or settings.
|
|
</Trans>
|
|
)
|
|
) : permissionStatus === 'granted' ? (
|
|
<Trans>Connect a microphone and try again.</Trans>
|
|
) : (
|
|
i18n._(PRODUCT_NEEDS_MICROPHONE_ACCESS_DESCRIPTOR, {productName: RuntimeConfig.productName})
|
|
)}
|
|
</p>
|
|
</div>
|
|
{permissionStatus !== 'granted' ? (
|
|
<Button
|
|
variant="secondary"
|
|
small={true}
|
|
onClick={() => {
|
|
void requestPermission();
|
|
}}
|
|
data-flx="user.voice-tab.button"
|
|
>
|
|
<Trans>Allow microphone</Trans>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<CompactComboboxRow
|
|
label={i18n._(VOICE_INPUT_DEVICE_DESCRIPTOR)}
|
|
value={effectiveInputDeviceId}
|
|
options={inputDeviceOptions}
|
|
onChange={handleInputDeviceChange}
|
|
controlWidth="wide"
|
|
menuMinWidth={280}
|
|
dataFlx="user.voice-tab.select.input-device-change"
|
|
data-flx="user.user-voice-tab.voice-tab.compact-combobox-row.input-device-change"
|
|
/>
|
|
<CompactComboboxRow
|
|
label={i18n._(VOICE_OUTPUT_DEVICE_DESCRIPTOR)}
|
|
description={
|
|
!canSelectOutputDevice ? (
|
|
<Trans>Voice uses your system output device in this browser. Change it in your system settings.</Trans>
|
|
) : null
|
|
}
|
|
value={effectiveOutputDeviceId}
|
|
options={outputDeviceOptions}
|
|
disabled={!canSelectOutputDevice}
|
|
onChange={(value) => VoiceSettingsCommands.update({outputDeviceId: value})}
|
|
controlWidth="wide"
|
|
menuMinWidth={280}
|
|
dataFlx="user.voice-tab.select.update"
|
|
data-flx="user.user-voice-tab.voice-tab.compact-combobox-row.update"
|
|
/>
|
|
</SettingsTabSection>
|
|
<SettingsTabSection title={i18n._(VOLUME_DESCRIPTOR)} data-flx="user.voice-tab.volume-section">
|
|
<div data-flx="user.voice-tab.div--3">
|
|
<div className={styles.sliderLabelRow} data-flx="user.voice-tab.slider-label-row">
|
|
<div className={styles.sliderLabel} data-flx="user.voice-tab.slider-label">
|
|
{i18n._(VOICE_INPUT_VOLUME_DESCRIPTOR)}
|
|
</div>
|
|
<SliderResetIconButton
|
|
canReset={canResetSliderValue(inputVolume, 100)}
|
|
onReset={() => VoiceSettingsCommands.update({inputVolume: 100})}
|
|
ariaLabel={resetSliderLabel}
|
|
dataFlx="user.voice-tab.reset-button.input-volume"
|
|
data-flx="user.user-voice-tab.voice-tab.slider-reset-icon-button"
|
|
/>
|
|
</div>
|
|
<Slider
|
|
value={inputVolume}
|
|
defaultValue={inputVolume}
|
|
factoryDefaultValue={100}
|
|
minValue={0}
|
|
maxValue={VOICE_VOLUME_MAX_PERCENT}
|
|
step={1}
|
|
markers={[0, 50, 100, 150, 200]}
|
|
stickToMarkers={false}
|
|
onMarkerRender={formatPercentage}
|
|
onValueRender={formatPercentage}
|
|
onValueChange={(value) => VoiceSettingsCommands.update({inputVolume: value})}
|
|
data-flx="user.voice-tab.slider"
|
|
/>
|
|
</div>
|
|
<div data-flx="user.voice-tab.div--4">
|
|
<div className={styles.sliderLabelRow} data-flx="user.voice-tab.slider-label-row--2">
|
|
<div className={styles.sliderLabel} data-flx="user.voice-tab.slider-label--2">
|
|
{i18n._(VOICE_OUTPUT_VOLUME_DESCRIPTOR)}
|
|
</div>
|
|
<SliderResetIconButton
|
|
canReset={canResetSliderValue(outputVolume, 100)}
|
|
onReset={() => VoiceSettingsCommands.update({outputVolume: 100})}
|
|
ariaLabel={resetSliderLabel}
|
|
dataFlx="user.voice-tab.reset-button.output-volume"
|
|
data-flx="user.user-voice-tab.voice-tab.slider-reset-icon-button--2"
|
|
/>
|
|
</div>
|
|
<Slider
|
|
value={outputVolume}
|
|
defaultValue={outputVolume}
|
|
factoryDefaultValue={100}
|
|
minValue={0}
|
|
maxValue={VOICE_VOLUME_MAX_PERCENT}
|
|
step={1}
|
|
markers={[0, 50, 100, 150, 200]}
|
|
stickToMarkers={false}
|
|
onMarkerRender={formatPercentage}
|
|
onValueRender={formatPercentage}
|
|
onValueChange={(value) => VoiceSettingsCommands.update({outputVolume: value})}
|
|
data-flx="user.voice-tab.slider--2"
|
|
/>
|
|
</div>
|
|
</SettingsTabSection>
|
|
<SettingsTabSection title={<Trans>Voice processing</Trans>} data-flx="user.voice-tab.voice-processing-section">
|
|
<div className={styles.audioProcessing} data-flx="user.voice-tab.audio-processing">
|
|
{showExternalProcessorHint && externalProcessor != null && (
|
|
<WarningAlert
|
|
link={{
|
|
label: <Trans>Switch to direct input</Trans>,
|
|
onClick: () => handleVoiceProcessingModeChange('studio'),
|
|
}}
|
|
data-flx="user.voice-tab.warning-alert"
|
|
>
|
|
<Trans>
|
|
Looks like you're using {externalProcessor.name}. Stacking{' '}
|
|
{ph({PRODUCT_NAME: RuntimeConfig.productName})}'s processing on top can cause pumping or distortion.
|
|
Direct input passes your audio through untouched.
|
|
</Trans>
|
|
</WarningAlert>
|
|
)}
|
|
<div className={styles.audioProcessingCard} data-flx="user.voice-tab.audio-processing-card">
|
|
<RadioGroup
|
|
options={profileOptions}
|
|
value={voiceProcessingMode}
|
|
onChange={handleVoiceProcessingModeChange}
|
|
aria-label={i18n._(SELECT_VOICE_PROCESSING_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.radio-group.voice-processing-mode-change"
|
|
/>
|
|
{voiceProcessingMode === 'voice' && noiseSuppressionFallbackMessage && (
|
|
<p
|
|
className={styles.pttSettingDescription}
|
|
data-flx="user.user-voice-tab.voice-tab.ptt-setting-description"
|
|
>
|
|
{noiseSuppressionFallbackMessage}
|
|
</p>
|
|
)}
|
|
{voiceProcessingMode === 'voice' && (
|
|
<div className={styles.profileSubSection} data-flx="user.voice-tab.profile-sub-section">
|
|
{renderPttControls()}
|
|
{renderAutoGainControlSwitch()}
|
|
</div>
|
|
)}
|
|
{voiceProcessingMode === 'studio' && stereoMicrophoneAvailable && (
|
|
<div className={styles.profileSubSection} data-flx="user.voice-tab.studio-profile-sub-section">
|
|
{renderStereoMicrophoneSwitch('user.voice-tab.studio-profile.switch.set-stereo-microphone')}
|
|
</div>
|
|
)}
|
|
{voiceProcessingMode === 'studio' && isPushToTalk && Keybind.hasPushToTalkKeybind() && (
|
|
<WarningAlert data-flx="user.voice-tab.warning-alert--2">
|
|
<Trans>Push-to-talk is ignored in direct input. Switch to focused voice or custom to use it.</Trans>
|
|
</WarningAlert>
|
|
)}
|
|
{voiceProcessingMode === 'custom' && renderCustomProfile()}
|
|
</div>
|
|
</div>
|
|
</SettingsTabSection>
|
|
<SettingsTabSection title={i18n._(MIC_TEST_DESCRIPTOR)} data-flx="user.voice-tab.mic-test-section-wrapper">
|
|
<MicTestSection
|
|
settings={{
|
|
inputDeviceId: effectiveInputDeviceId,
|
|
outputDeviceId: effectiveOutputDeviceId,
|
|
inputVolume,
|
|
outputVolume,
|
|
echoCancellation,
|
|
autoGainControl,
|
|
voiceProcessingMode,
|
|
stereoMicrophone: isStereoMicrophoneEnabled(),
|
|
}}
|
|
data-flx="user.voice-tab.mic-test-section"
|
|
/>
|
|
</SettingsTabSection>
|
|
<SettingsTabSection
|
|
title={i18n._(ENTRANCE_SOUND_DESCRIPTOR)}
|
|
data-flx="user.voice-tab.entrance-sound-section-wrapper"
|
|
>
|
|
<EntranceSoundSection data-flx="user.voice-tab.entrance-sound-section" />
|
|
</SettingsTabSection>
|
|
</>
|
|
);
|
|
});
|