Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/modules.calls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ export { Call, CompactView, useModeSync, ModeSyncProvider } from './src';
export { LiveKitProvider } from './src/providers/LiveKitProvider';
export { RoomProvider } from './src/providers/RoomProvider';
export { useCallStore } from './src/store/callStore';
export { useUserChoicesStore } from './src/store/userChoices';
export type { LocalUserChoices } from './src/store/userChoices';
export { useStartCall } from './src/hooks';
export { useUmamiActivityHeartbeat } from './src';
1 change: 1 addition & 0 deletions packages/modules.calls/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export { useParticipantJoinSync } from './useParticipantJoinSync';
export { useUmamiActivityHeartbeat } from './useUmamiActivityHeartbeat';
export { useSortedTracks } from './useSortedTracks';
export { useDocumentPiP } from './useDocumentPiP';
export { useNoiseGate } from './useNoiseGate';
164 changes: 164 additions & 0 deletions packages/modules.calls/src/hooks/useNoiseGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEffect, useRef } from 'react';
import { useLocalParticipant } from '@livekit/components-react';
import type { LocalAudioTrack } from 'livekit-client';
import { useUserChoicesStore } from '../store/userChoices';

/**
* Границы RMS-шкалы. Совпадают с SoundAndVideo.tsx —
* при изменении нужно обновлять оба файла.
*/
const RMS_FLOOR = 0.002;
const RMS_CEIL = 0.3;
const LOG_RANGE = Math.log(RMS_CEIL / RMS_FLOOR);

function sliderToRms(slider: number): number {
if (slider <= 0) return 0;
return RMS_FLOOR * Math.exp(slider * LOG_RANGE);
}

type GateState = 'closed' | 'open' | 'holding';

/**
* Noise gate для микрофона в LiveKit-звонке.
*
* Два ключевых момента:
*
* 1. audioTrack.mediaStreamTrack — это ГЕТТЕР, который может
* возвращать разные объекты MediaStreamTrack при смене
* процессоров (Krisp), устройства или реконнекте. Поэтому
* нельзя захватывать ссылку один раз — нужно перечитывать
* на каждом тике.
*
* 2. Analyser подключён к КЛОНУ текущего трека. Клон всегда
* active и отдаёт реальный звук с микрофона, даже когда
* оригинал отключён через enabled=false. При смене
* underlying-трека клон пересоздаётся автоматически.
*
* State machine:
* CLOSED → (rms ≥ openThreshold за MIN_OPEN_MS мс) → OPEN
* OPEN → (rms < closeThreshold) → HOLDING
* HOLDING → (rms ≥ closeThreshold) → OPEN
* HOLDING → (hold timer ≥ HOLD_MS) → CLOSED
*
* Hysteresis: closeThreshold = openThreshold × 0.75
*/
export function useNoiseGate() {
const threshold = useUserChoicesStore((s) => s.micInputSensitivity) ?? 0;
const { microphoneTrack } = useLocalParticipant();
const audioTrack = microphoneTrack?.track as LocalAudioTrack | undefined;

const ctxRef = useRef<AudioContext | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

useEffect(() => {
if (!audioTrack || threshold <= 0) return;

const ctx = new AudioContext();
ctxRef.current = ctx;

const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;

let monitorTrack: MediaStreamTrack | null = null;
let monitorSource: MediaStreamAudioSourceNode | null = null;
let trackedTrackId = '';

const timeDomain = new Float32Array(analyser.fftSize);

const HOLD_MS = 600;
const MIN_OPEN_MS = 20;
const HYSTERESIS = 0.75;

const gateThreshold = sliderToRms(threshold);
const openThreshold = gateThreshold;
const closeThreshold = gateThreshold * HYSTERESIS;

let state: GateState = 'closed';
let aboveSince = 0;
let belowSince = 0;

const initialTrack = audioTrack.mediaStreamTrack;
if (initialTrack) {
trackedTrackId = initialTrack.id;
monitorTrack = initialTrack.clone();
monitorTrack.enabled = true;
initialTrack.enabled = false;
monitorSource = ctx.createMediaStreamSource(new MediaStream([monitorTrack]));
monitorSource.connect(analyser);
}

intervalRef.current = setInterval(() => {
const msTrack = audioTrack.mediaStreamTrack;
if (!msTrack) return;

if (msTrack.id !== trackedTrackId) {
monitorSource?.disconnect();
monitorTrack?.stop();
monitorTrack = msTrack.clone();
monitorTrack.enabled = true;
trackedTrackId = msTrack.id;
monitorSource = ctx.createMediaStreamSource(new MediaStream([monitorTrack]));
monitorSource.connect(analyser);
msTrack.enabled = false;
state = 'closed';
aboveSince = 0;
belowSince = 0;
}

const now = performance.now();

analyser.getFloatTimeDomainData(timeDomain);
let sum = 0;
for (let i = 0; i < timeDomain.length; i++) {
sum += timeDomain[i] * timeDomain[i];
}
const rms = Math.sqrt(sum / timeDomain.length);

switch (state) {
case 'closed':
if (rms >= openThreshold) {
if (aboveSince === 0) aboveSince = now;
if (now - aboveSince >= MIN_OPEN_MS) {
msTrack.enabled = true;
state = 'open';
aboveSince = 0;
}
} else {
aboveSince = 0;
}
break;

case 'open':
if (rms < closeThreshold) {
state = 'holding';
belowSince = now;
}
break;

case 'holding':
if (rms >= closeThreshold) {
state = 'open';
belowSince = 0;
} else if (now - belowSince >= HOLD_MS) {
msTrack.enabled = false;
state = 'closed';
belowSince = 0;
}
break;
}
}, 10);

return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
monitorTrack?.stop();
monitorSource?.disconnect();
ctx.close();
ctxRef.current = null;
const msTrack = audioTrack.mediaStreamTrack;
if (msTrack) msTrack.enabled = true;
};
}, [audioTrack, threshold]);
}
3 changes: 2 additions & 1 deletion packages/modules.calls/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export {
type UseNoiseCancellationOptions,
type UseNoiseCancellationResult,
} from './hooks';
export { useCallStore } from './store';
export { useCallStore, useUserChoicesStore } from './store';
export { usePersistentUserChoices } from './hooks';
7 changes: 7 additions & 0 deletions packages/modules.calls/src/providers/LiveKitProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { useParams, useLocation, useNavigate, useSearch } from '@tanstack/react-
import { useEffect, useRef } from 'react';
import { Track } from 'livekit-client';
import { PiPProvider } from './PiPProvider';
import { useNoiseGate } from '../hooks/useNoiseGate';

function NoiseGateEffect() {
useNoiseGate();
return null;
}

type LiveKitProviderProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -239,6 +245,7 @@ export const LiveKitProvider = ({ children }: LiveKitProviderProps) => {
audio={audioEnabled || false}
video={videoEnabled || false}
>
<NoiseGateEffect />
<PiPProvider>{children}</PiPProvider>
</LiveKitRoom>
);
Expand Down
1 change: 1 addition & 0 deletions packages/modules.calls/src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useCallStore } from './callStore';
export { useUserChoicesStore } from './userChoices';
3 changes: 3 additions & 0 deletions packages/modules.calls/src/store/userChoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type LocalUserChoices = LocalUserChoicesLK & {
noiseCancellationEnabled?: boolean;
/** Режим шумоподавления: off | webrtc | krisp. */
noiseCancellationMode?: NoiseCancellationMode;
/** Порог чувствительности микрофона (0–1). 0 = noise gate выключен, >0 = порог срабатывания. */
micInputSensitivity?: number;
};

function getUserChoicesState(): LocalUserChoices {
Expand All @@ -31,6 +33,7 @@ function getUserChoicesState(): LocalUserChoices {
videoSubscribeQuality: VideoQuality.HIGH,
noiseCancellationEnabled: false,
noiseCancellationMode: 'webrtc',
micInputSensitivity: 0,
...loadUserChoices(),
};
}
Expand Down
38 changes: 36 additions & 2 deletions packages/modules.calls/src/ui/Up/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router';
import {
Sheet,
SheetClose,
Expand All @@ -8,7 +9,15 @@ import {
SheetTitle,
SheetTrigger,
} from '@xipkg/sheet';
import { Close, Conference, Microphone, SoundTwo, Chat, Hand } from '@xipkg/icons';
import {
Close,
Conference,
Microphone,
SoundTwo,
Chat,
Hand,
Settings as SettingsIcon,
} from '@xipkg/icons';
import { Label } from '@xipkg/label';
import { Switch } from '@xipkg/switcher';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@xipkg/select';
Expand Down Expand Up @@ -85,6 +94,13 @@ const DeviceSelector = ({
};

export const Settings = ({ children }: SettingsPropsT) => {
const navigate = useNavigate();
const { pathname } = useLocation();
const search = useSearch({ strict: false }) as {
profile?: string;
call?: string;
classroom?: string;
};
const { room } = useRoom();
const { microphoneTrack, cameraTrack, isMicrophoneEnabled, isCameraEnabled } =
useLocalParticipant();
Expand Down Expand Up @@ -215,6 +231,13 @@ export const Settings = ({ children }: SettingsPropsT) => {
[saveAudioOutputDeviceId],
);

const handleOpenSoundSettings = useCallback(() => {
navigate({
to: pathname,
search: { ...search, profile: 'soundAndVideo' },
});
}, [navigate, pathname, search]);

// Обработчики включения/выключения
const handleMicrophoneToggle = useCallback(async () => {
microphoneToggle.toggle();
Expand All @@ -229,7 +252,7 @@ export const Settings = ({ children }: SettingsPropsT) => {
<SheetTrigger className="ml-2" asChild>
{children}
</SheetTrigger>
<SheetContent className="bg-gray-0 w-[400px] rounded-tl-2xl rounded-bl-2xl border-none p-4 shadow-2xl">
<SheetContent className="bg-gray-0 w-full max-w-[400px] rounded-tl-2xl rounded-bl-2xl border-none p-4 shadow-2xl">
<SheetHeader className="mb-6 flex h-10 flex-row items-center justify-between space-y-0">
<SheetTitle className="text-gray-100">Настройки</SheetTitle>
<SheetClose className="hover:bg-gray-5 mt-0 rounded-md bg-transparent p-1">
Expand Down Expand Up @@ -307,6 +330,17 @@ export const Settings = ({ children }: SettingsPropsT) => {
/>
</div>

<Button
type="button"
size="s"
variant="ghost"
onClick={handleOpenSoundSettings}
className="w-full justify-start gap-2"
>
<SettingsIcon className="text-gray-60 h-4 w-4" />
Расширенные настройки звука
</Button>

{/* Размытие фона */}
{isBlurSupported && (
<div className="space-y-3">
Expand Down
1 change: 1 addition & 0 deletions packages/modules.profile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@xipkg/toggle": "^2.0.13",
"@xipkg/userprofile": "4.0.14",
"@xipkg/utils": "1.8.0",
"modules.calls": "workspace:*",
"common.api": "workspace:*",
"common.auth": "workspace:*",
"common.config": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions packages/modules.profile/src/ui/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Secure } from './Secure';
import { PersonalData } from './PersonalData';
import { Notifications } from './Notifications';
import { TechnicalReport } from './TechnicalReport';
import { SoundAndVideo } from './SoundAndVideo';

type ComponentMapT = {
[key: string]: ReactElement;
Expand All @@ -13,6 +14,7 @@ const componentMap: ComponentMapT = {
personalInfo: <PersonalData />,
personalisation: <Customization />,
security: <Secure />,
soundAndVideo: <SoundAndVideo />,
notifications: <Notifications />,
report: <TechnicalReport />,
};
Expand Down
1 change: 1 addition & 0 deletions packages/modules.profile/src/ui/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const menuLabels = [
'Личные данные',
// 'Персонализация',
'Безопасность',
'Звук и видео',
'Уведомления',
'Отчёт',
];
Expand Down
8 changes: 7 additions & 1 deletion packages/modules.profile/src/ui/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Account, Exit, Key, Palette, Notification, File } from '@xipkg/icons';
import { Account, Exit, Key, Palette, Notification, File, SoundOn } from '@xipkg/icons';
import { Dispatch, SetStateAction } from 'react';
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router';
import { useAuth } from 'common.auth';
Expand All @@ -21,6 +21,10 @@ const options: ItemT[] = [
name: 'Безопасность',
query: 'security',
},
{
name: 'Звук и видео',
query: 'soundAndVideo',
},
{
name: 'Уведомления',
query: 'notifications',
Expand Down Expand Up @@ -60,6 +64,8 @@ const Item = ({ index, item, onMenuItemChange }: ItemPropsT) => {
return <Palette className={iconClasses} key="palette-icon" />;
case 'security':
return <Key className={iconClasses} key="key-icon" />;
case 'soundAndVideo':
return <SoundOn className={iconClasses} key="sound-icon" />;
case 'notifications':
return <Notification className={iconClasses} key="notification-icon" />;
case 'report':
Expand Down
Loading
Loading