From f5ee7001e3f00637311ea6f845c64a5fcbdd1768 Mon Sep 17 00:00:00 2001 From: SupertigerDev Date: Sat, 19 Oct 2024 10:37:54 +0100 Subject: [PATCH] finish adding push to talk --- src/chat-api/events/voiceEvents.ts | 19 ++++-- src/chat-api/store/useVoiceUsers.ts | 86 +++++++++++++++++++++--- src/common/arrayEquals.ts | 6 ++ src/components/settings/CallSettings.tsx | 12 +++- src/components/ui/RadioBox.tsx | 61 +++++++++++------ 5 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 src/common/arrayEquals.ts diff --git a/src/chat-api/events/voiceEvents.ts b/src/chat-api/events/voiceEvents.ts index 5ebaead3..a16e2ceb 100644 --- a/src/chat-api/events/voiceEvents.ts +++ b/src/chat-api/events/voiceEvents.ts @@ -4,13 +4,16 @@ import useAccount from "../store/useAccount"; import useVoiceUsers from "../store/useVoiceUsers"; export function onVoiceUserJoined(payload: RawVoice) { - const {createVoiceUser} = useVoiceUsers(); + const { createVoiceUser } = useVoiceUsers(); createVoiceUser(payload); } -export function onVoiceUserLeft(payload: {userId: string, channelId: string}) { - const {removeVoiceUser, setCurrentChannelId} = useVoiceUsers(); - const {user} = useAccount(); +export function onVoiceUserLeft(payload: { + userId: string; + channelId: string; +}) { + const { removeVoiceUser, setCurrentChannelId } = useVoiceUsers(); + const { user } = useAccount(); if (user()?.id === payload.userId) { setCurrentChannelId(null); @@ -27,7 +30,11 @@ interface VoiceSignalReceivedPayload { export function onVoiceSignalReceived(payload: VoiceSignalReceivedPayload) { const voiceUsers = useVoiceUsers(); - const voiceUser = voiceUsers.getVoiceUser(payload.channelId, payload.fromUserId); + + const voiceUser = voiceUsers.getVoiceUser( + payload.channelId, + payload.fromUserId + ); if (!voiceUser) return; if (!voiceUser.peer) { @@ -35,4 +42,4 @@ export function onVoiceSignalReceived(payload: VoiceSignalReceivedPayload) { } voiceUsers.signal(voiceUser, payload.signal); -} \ No newline at end of file +} diff --git a/src/chat-api/store/useVoiceUsers.ts b/src/chat-api/store/useVoiceUsers.ts index ae738a22..471fdd87 100644 --- a/src/chat-api/store/useVoiceUsers.ts +++ b/src/chat-api/store/useVoiceUsers.ts @@ -1,16 +1,23 @@ import { createStore, reconcile } from "solid-js/store"; import { RawVoice } from "../RawData"; -import { batch, createEffect, createSignal } from "solid-js"; +import { batch, createEffect, createMemo, createSignal, on } from "solid-js"; import { getCachedCredentials } from "../services/VoiceService"; import { emitVoiceSignal } from "../emits/voiceEmits"; import type SimplePeer from "@thaunknown/simple-peer"; import useUsers, { User } from "./useUsers"; import LazySimplePeer from "@/components/LazySimplePeer"; -import { getStorageString, StorageKeys } from "@/common/localStorage"; +import { + getStorageObject, + getStorageString, + StorageKeys, + useVoiceInputMode, +} from "@/common/localStorage"; import useAccount from "./useAccount"; import { set } from "idb-keyval"; import vad from "voice-activity-detection"; +import { downKeys, useGlobalKey } from "@/common/GlobalKey"; +import { arrayEquals } from "@/common/arrayEquals"; const createIceServers = () => [ getCachedCredentials(), @@ -73,6 +80,49 @@ const [currentVoiceUser, setCurrentVoiceUser] = createSignal< CurrentVoiceUser | undefined >(undefined); +const { start, stop } = useGlobalKey(); +const [voiceMode] = useVoiceInputMode(); + +createEffect( + on(currentVoiceUser, (current) => { + stop(); + if (!current?.channelId) return; + if (voiceMode() !== "PTT") return; + start(); + }) +); + +const micTrack = createMemo(() => { + const current = currentVoiceUser(); + return current?.audioStream?.getAudioTracks()[0]; +}); + +createEffect( + on( + () => downKeys.length, + () => { + const bound = getStorageObject(StorageKeys.PTTBoundKeys, []); + if (!bound.length) return; + const mic = micTrack(); + if (!mic) return; + const current = currentVoiceUser(); + if (!current) return; + + if (!arrayEquals(downKeys, bound)) { + mic.enabled = false; + setVoiceUsers(current.channelId, useAccount().user()?.id!, { + voiceActivity: false, + }); + return; + } + mic.enabled = true; + setVoiceUsers(current.channelId, useAccount().user()?.id!, { + voiceActivity: true, + }); + } + ) +); + const setCurrentChannelId = (channelId: string | null) => { const current = currentVoiceUser(); if (current?.channelId) { @@ -328,8 +378,10 @@ function createVadInstance( } : { minNoiseLevel: 0, - noiseCaptureDuration: 0, + noiseCaptureDuration: 100, avgNoiseMultiplier: 0.1, + maxNoiseLevel: 0.01, + onUpdate: console.log, }), onVoiceStart: function () { @@ -413,12 +465,30 @@ const toggleMic = async () => { audio: !deviceId ? true : { deviceId: JSON.parse(deviceId) }, video: false, }); - const vadStream = await navigator.mediaDevices.getUserMedia({ - audio: !deviceId ? true : { deviceId: JSON.parse(deviceId) }, - video: false, - }); + + let vadStream: MediaStream | undefined; + let vadInstance: ReturnType | undefined; + + if (voiceMode() === "OPEN") { + setVoiceUsers(current.channelId, useAccount().user()?.id!, { + voiceActivity: true, + }); + } + + if (voiceMode() !== "OPEN") { + stream.getAudioTracks()[0]!.enabled = false; + } + + if (voiceMode() === "VOICE_ACTIVITY") { + vadStream = await navigator.mediaDevices.getUserMedia({ + audio: !deviceId ? true : { deviceId: JSON.parse(deviceId) }, + video: false, + }); + vadInstance = createVadInstance(vadStream, stream); + } + addStreamToPeers(stream); - const vadInstance = createVadInstance(vadStream, stream); + setCurrentVoiceUser({ ...current, audioStream: stream, diff --git a/src/common/arrayEquals.ts b/src/common/arrayEquals.ts new file mode 100644 index 00000000..d57774a3 --- /dev/null +++ b/src/common/arrayEquals.ts @@ -0,0 +1,6 @@ +export function arrayEquals( + a: A, + b: B +) { + return a.length === b.length && a.every((v, i) => v === b[i]); +} diff --git a/src/components/settings/CallSettings.tsx b/src/components/settings/CallSettings.tsx index edf225a8..b4608cf9 100644 --- a/src/components/settings/CallSettings.tsx +++ b/src/components/settings/CallSettings.tsx @@ -136,12 +136,22 @@ const InputModeRadioBoxContainer = styled(FlexColumn)` function InputMode() { const [inputMode, setInputMode] = useVoiceInputMode(); + const store = useStore(); + + const isInCall = () => store.voiceUsers.currentUser()?.channelId; return (
- + { + if (isInCall()) { + alert("You must leave the call first."); + } + }} + > setInputMode(e.id)} items={[ diff --git a/src/components/ui/RadioBox.tsx b/src/components/ui/RadioBox.tsx index 7c8355e0..7c8c5f2c 100644 --- a/src/components/ui/RadioBox.tsx +++ b/src/components/ui/RadioBox.tsx @@ -1,7 +1,7 @@ import { styled } from "solid-styled-components"; import { FlexColumn, FlexRow } from "./Flexbox"; import Text from "./Text"; -import { For, createEffect, createSignal } from "solid-js"; +import { For, JSX, createEffect, createSignal } from "solid-js"; import { classNames, conditionalClass } from "@/common/classNames"; export interface RadioBoxItem { @@ -11,12 +11,12 @@ export interface RadioBoxItem { interface RadioBoxProps { onChange?(item: RadioBoxItem): void; - items: RadioBoxItem[] + items: RadioBoxItem[]; initialId: string | number; + style?: JSX.CSSProperties; } -const RadioBoxContainer = styled(FlexColumn)` -`; +const RadioBoxContainer = styled(FlexColumn)``; export function RadioBox(props: RadioBoxProps) { const [selectedId, setSelectedId] = createSignal(props.initialId); @@ -32,28 +32,33 @@ export function RadioBox(props: RadioBoxProps) { }; return ( - + - {item => onClick(item)} item={item} selected={item.id === selectedId()} />} + {(item) => ( + onClick(item)} + item={item} + selected={item.id === selectedId()} + /> + )} ); } - -export const RadioBoxItemCheckBox = styled(FlexRow)<{size?: number}>` +export const RadioBoxItemCheckBox = styled(FlexRow)<{ size?: number }>` position: relative; - width: ${props => props.size || 10}px; - height: ${props => props.size || 10}px; + width: ${(props) => props.size || 10}px; + height: ${(props) => props.size || 10}px; border-radius: 50%; background-color: rgba(255, 255, 255, 0.1); transition: 0.2s; - border: solid ${props => (props.size || 10) / 2}px transparent; + border: solid ${(props) => (props.size || 10) / 2}px transparent; &:after { position: absolute; content: ""; - inset: -${props => (props.size || 10) / 2}px; + inset: -${(props) => (props.size || 10) / 2}px; border: solid 1px rgba(255, 255, 255, 0.2); border-radius: 50%; } @@ -63,10 +68,6 @@ export const RadioBoxItemCheckBox = styled(FlexRow)<{size?: number}>` border-color: var(--primary-color); } flex-shrink: 0; - - - - `; const RadioBoxItemContainer = styled(FlexRow)` @@ -84,8 +85,8 @@ const RadioBoxItemContainer = styled(FlexRow)` &:not(.selected):hover { .radio-box-circle { - background-color: rgba(255,255,255,0.6); - border-color: rgba(0,0,0,0.4); + background-color: rgba(255, 255, 255, 0.6); + border-color: rgba(0, 0, 0, 0.4); } } `; @@ -101,9 +102,25 @@ interface RadioBoxItemProps { export function RadioBoxItem(props: RadioBoxItemProps) { return ( - - - {props.item.label} + + + + {props.item.label} + ); -} \ No newline at end of file +}