마이크 연결
다시 시작이다!
요즘 취준도 하고 이력서, 자소서도 넣고, 서버 이관으로 인해 aurora 프로젝트가 잠시 중단되었다가 저번주에 다시 시작되었다. 그에 따라서 한 주동안 내가 작성했던 코드를 다시 살펴보며, 자잘한 변경이 있었는데 우선적으로 서버장은 서버 멤버의 권한을 변경할 수 있는 권한을 주었고 기존에 서버에서의 활성화 여부를 확인하기 위한 status 값을 프로젝트와 채널과 구분할 수 있도록 s_status 값으로 변경 해주었다. 그렇게 서버, 프로젝트 로직은 어느정도 완성이 되어서 이제 메인 기능인 mediaStream 도입을 하기로 했다.
마이크 먼저..
우선, 카메라는 기존에 구현을 해놨기에 카메라의 경우를 살펴보며 마이크 설정을 구현하게 되었다.
기초가 되는 마이크 관련 훅을 작성해주고 redux에 연결 UI 에 반영하는 것이 목표였다.
useMike()
typescriptexport const useMike() => {
const [mikeStream, setMikeStream] = useState<MediaStream | null>(null);
const [mikeError, setMikeError] = useState<string | null>(null);
const startMike = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
setMikeStream(stream);
setMikeError(null);
} catch(error){
setMikeError(error instance of Error ? error.message : "Unknown error");
setMikeStream(null);
}
}
const stopMike = () => {
if (mikeStream) {
mikeStream.getAudioTracks().forEach((track) => track.stop()));
setmikeStream(null);
}
};
const toggleMike = () => {
if (mikeStream) {
stopMike();
} else {
startMike();
}
}
return {
mikeStream,
setMikeStream,
mikeError,
setMikeError,
startMike,
stopMike,
toggleMike,
}
}
useMediaControl()
typescriptimport { useCallback, useEffect } from "react";
import { useCamera } from "./useCamera";
import { useLoadRedux } from "./useLoadRedux";
import { useMike } from "./useMike";
export const useMediaControl = () => {
// 카메라 제어
const { cameraStream, startCamera, stopCamera } = useCamera();
const { isVideoOn, toggleMyVideo, setSpeaking, isMicOn, toggleMyMic } =
useLoadRedux();
// 마이크 제어
const { mikeStream, startMike, stopMike } = useMike();
// 음성 채널 입장 시 바로 마이크 on
useEffect(() => {
startMike();
// 클린 업 함수로 마이크 중지
return () => {
stopMike();
};
}, []);
// 비디오 토글
const handleToggleVideo = useCallback(() => {
// 1. Redux 상태 먼저 업데이트
toggleMyVideo();
// 2. 현재 상태에 따라 카메라 제어
if (isVideoOn) {
stopCamera(); // 현재 켜져있으면 끄기
} else {
startCamera(); // 현재 꺼져있으면 켜기
}
}, [isVideoOn, toggleMyVideo, startCamera, stopCamera, cameraStream]);
// 마이크 토글
const handleToggleMic = useCallback(() => {
// 1. Redux 상태 업데이트
toggleMyMic();
// 2. 현재 상태에 따라 마이크 제어
if (isMicOn) {
stopMike(); // 켜져 있으면 끄기
} else {
startMike(); // 꺼져 있으면 켜기
}
}, [isMicOn, toggleMyMic, startMike, stopMike]);
return {
cameraStream,
startCamera,
stopCamera,
handleToggleVideo,
handleToggleMic,
mikeStream,
startMike,
stopMike,
};
};
page.tsx
typescript"use client";
import { useVoiceChannelPage } from "../../../../../hooks/useVoiceChannelPage";
import { useVoiceGrid } from "../../../../../hooks/useVoiceGrid";
import { useMediaControl } from "../../../../../hooks/useMediaControl";
import { useResponsive } from "../../../../../../lib/useResponsive";
import dynamic from "next/dynamic";
import {
VoiceHeader,
ScreenShareView,
VoiceGrid,
VoiceControlBar,
} from "../components";
// FullscreenButton을 클라이언트에서만 로드 (hydration 에러 방지)
const FullscreenButton = dynamic(
() =>
import("../components").then((mod) => ({ default: mod.FullscreenButton })),
{
ssr: false,
loading: () => null, // 로딩 중에는 아무것도 보여주지 않음
}
);
const VoiceChannelPage = () => {
const { isMobile } = useResponsive();
const {
channelId,
getChannelName,
currentUserId,
isMicOn,
isFullScreen,
isVideoOn,
isScreenShareActive,
isScreenSharing,
participants,
participantCount,
toggleFullScreen,
toggleScreenShare,
} = useVoiceChannelPage();
// 미디어 제어 (카메라 포함)
const { cameraStream, handleToggleVideo, mikeStream, handleToggleMic } =
useMediaControl();
// 참여자 수에 따른 그리드 레이아웃 계산
const { gridLayout, gridRows } = useVoiceGrid(
channelId,
participantCount,
isMicOn,
isVideoOn,
isScreenShareActive,
participants
);
return (
<div className="h-full bg-aurora-voice text-white flex flex-col relative">
{/* 상단 헤더 */}
<VoiceHeader channelName={getChannelName(channelId)} />
{/* 메인 비디오 영역 */}
<div
className={`
flex-1 flex items-center justify-center
${isMobile ? "p-4" : "p-8"}
`}
>
{isScreenShareActive ? (
/* 화면 공유 모드 */
<ScreenShareView
participants={participants}
currentUserId={currentUserId}
cameraStream={cameraStream}
/>
) : (
/* 일반 화상회의 모드 */
<VoiceGrid
participants={participants}
gridLayout={gridLayout}
gridRows={gridRows}
currentUserId={currentUserId}
cameraStream={cameraStream}
mikeStream={mikeStream}
/>
)}
</div>
{/* 하단 컨트롤 바 */}
<VoiceControlBar
isMicOn={isMicOn}
isVideoOn={isVideoOn}
isScreenSharing={isScreenSharing}
onToggleMic={handleToggleMic}
onToggleVideo={handleToggleVideo}
onToggleScreenShare={toggleScreenShare}
/>
{/* 전체화면 버튼 - 데스크탑/태블릿에서만 렌더링 */}
{!isMobile && (
<FullscreenButton
onToggleFullscreen={toggleFullScreen}
isFullScreen={isFullScreen}
/>
)}
</div>
);
};
export default VoiceChannelPage;
VoiceGrid.tst
typescriptimport { VoiceParticipantCard } from "./VoiceParticipantCard";
import { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
interface VoiceGridProps {
participants: { [userId: string]: VoiceParticipant };
gridLayout: string;
gridRows: string;
currentUserId?: string; // 현재 사용자 ID
cameraStream?: MediaStream | null; // 카메라 스트림
mikeStream?: MediaStream | null; // 마이크 스트림
}
export const VoiceGrid = ({
participants,
gridLayout,
gridRows,
currentUserId,
cameraStream,
mikeStream,
}: VoiceGridProps) => {
return (
<div
className={`grid ${gridLayout} ${gridRows} gap-4 w-full h-3/4 max-w-6xl`}
>
{Object.entries(participants).map(([userId, participant]) => (
<VoiceParticipantCard
key={userId}
participant={participant}
isCompact={false}
videoStream={
userId === currentUserId && participant.isVideoOn
? cameraStream || undefined
: undefined
}
mikeStream={
userId === currentUserId && participant.isMicOn
? mikeStream || undefined
: undefined
}
/>
))}
</div>
);
};
VoiceParticipantCard.tsx
typescriptimport { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
import { useEffect, useRef } from "react";
interface VoiceParticipantCardProps {
participant: VoiceParticipant;
isCompact?: boolean; // 화면 공유 모드일 때 작은 크기
videoStream?: MediaStream; // 비디오 스트림
mikeStream?: MediaStream; // 마이크 스트림
}
export const VoiceParticipantCard = ({
participant,
isCompact = false,
videoStream,
mikeStream,
}: VoiceParticipantCardProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const mikeRef = useRef<HTMLAudioElement>(null);
// 비디오 스트림 연결
useEffect(() => {
if (videoRef.current && videoStream) {
videoRef.current.srcObject = videoStream;
}
}, [videoStream, participant.isVideoOn, participant.username]);
// 마이크 스트림 연결
useEffect(() => {
if (mikeRef.current && mikeStream) {
mikeRef.current.srcObject = mikeStream;
}
}, [mikeStream, participant.isMicOn, participant.username]);
const avatarSize = isCompact ? "w-12 h-12" : "w-24 h-24";
const textSize = isCompact ? "text-xs" : "text-sm";
const iconSize = isCompact ? "w-4 h-4" : "w-8 h-8";
const namePosition = isCompact ? "bottom-2 left-2" : "bottom-4 left-4";
return (
<div
className={`bg-gray-800 rounded-lg ${
isCompact ? "p-4 aspect-video" : "aspect-video"
} flex flex-col items-center justify-center relative overflow-hidden ${
participant.isSpeaking ? "ring-2 ring-green-400" : ""
}`}
>
{/* 사용자 비디오 또는 아바타 */}
{participant.isVideoOn && videoStream ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
) : (
<div
className={`${avatarSize} bg-gray-600 rounded-full flex items-center justify-center ${
isCompact ? "text-xl mb-2" : "text-3xl mb-4"
}`}
>
{participant.username[0]}
</div>
)}
{/* 이름과 상태 아이콘 컨테이너 */}
<div className={`absolute ${namePosition} flex items-center gap-2`}>
{/* 사용자 이름 */}
<div className="bg-aurora-voice rounded-full px-5">
<span className={`${textSize} text-white`}>
{participant.username}
</span>
</div>
{/* 상태 표시 아이콘들 */}
<div className={`flex gap-${isCompact ? "1" : "2"}`}>
{!participant.isMicOn && (
<div
className={`${iconSize} bg-gray-500 rounded-full flex items-center justify-center`}
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M2.293 2.293a1 1 0 011.414 0L7 5.586V4a3 3 0 116 0v4c0 .57-.16 1.104-.44 1.563l1.828 1.828A6.966 6.966 0 0015 8a1 1 0 012 0 8.94 8.94 0 01-1.22 4.522l1.927 1.927a1 1 0 01-1.414 1.414L3.707 3.707a1 1 0 010-1.414zM10 11.414L7.586 9A3.001 3.001 0 007 8v3a3 3 0 003 .414z"
clipRule="evenodd"
/>
</svg>
</div>
)}
{!participant.isVideoOn && (
<div
className={`${iconSize} bg-gray-500 rounded-full flex items-center justify-center`}
>
<svg
className={`${isCompact ? "w-3 h-3" : "w-4 h-4"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
clipRule="evenodd"
/>
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
</svg>
</div>
)}
</div>
</div>
{/* 발언 중 애니메이션 */}
{participant.isSpeaking && (
<div className="absolute inset-0 border-2 border-green-400 rounded-lg animate-pulse"></div>
)}
</div>
);
};
이렇게 마이크 설정에 대해 정의 후 redux 와 연결, UI 에 반영까지 마쳤다

image.png
이렇게 권한을 받게 된다. 근데 여기서 문제가 발생하는데, 지금 당장 스피커 기능도 없기 때문에 내가 말하고 있는 지 확인할 방법이 없었다. 그래서 음성 감지 로직도 추가하게 되었는데
방법은 AudioContext 에 현재 stream 을 주입하고 AnalyserNode 로 현재 마이크의 볼륨을 체크하여 값이 변경되면 isSpeaking 값을 변경해주어 음성 감지가 되도록 하였다.
useMike()
typescriptimport { useRef, useState } from "react";
export const useMike = (onVolumeChange?: (isSpeaking: boolean) => void) => {
const [mikeStream, setMikeStream] = useState<MediaStream | null>(null);
const [mikeError, setMikeError] = useState<string | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const dataArrayRef = useRef<Uint8Array | null>(null);
const startMike = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
setMikeStream(stream);
setMikeError(null);
audioContextRef.current = new AudioContext();
const source = audioContextRef.current.createMediaStreamSource(stream);
analyserRef.current = audioContextRef.current.createAnalyser();
analyserRef.current.fftSize = 256;
const bufferLength = analyserRef.current.frequencyBinCount;
dataArrayRef.current = new Uint8Array(bufferLength);
noticeSpeech();
source.connect(analyserRef.current);
} catch (error) {
setMikeError(error instanceof Error ? error.message : "Unknown error");
setMikeStream(null);
}
};
const noticeSpeech = () => {
const analyser = analyserRef.current;
const dataArray = dataArrayRef.current;
if (!analyser || !dataArray) return;
const checkVolume = () => {
analyser.getByteFrequencyData(dataArray as any);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
const avg = sum / dataArray.length;
const isSpeaking = avg > 20;
onVolumeChange?.(isSpeaking);
requestAnimationFrame(checkVolume);
};
checkVolume();
};
const stopMike = () => {
if (mikeStream) {
mikeStream.getAudioTracks().forEach((track) => track.stop());
setMikeStream(null);
}
};
const toggleMike = () => {
if (mikeStream) {
stopMike();
} else {
startMike();
}
};
return {
mikeStream,
setMikeStream,
mikeError,
setMikeError,
startMike,
stopMike,
toggleMike,
};
};
useMediaControl()
typescriptimport { useCallback, useEffect } from "react";
import { useCamera } from "./useCamera";
import { useLoadRedux } from "./useLoadRedux";
import { useMike } from "./useMike";
export const useMediaControl = () => {
// 카메라 제어
const { cameraStream, startCamera, stopCamera } = useCamera();
const { isVideoOn, toggleMyVideo, setSpeaking, currentUserId, isMicOn, toggleMyMic } =
useLoadRedux();
// 마이크 제어
const { mikeStream, startMike, stopMike } = useMike((isSpeking) => {
setSpeaking(currentUserId, isSpeking);
});
// 음성 채널 입장 시 바로 마이크 on
useEffect(() => {
startMike();
// 클린 업 함수로 마이크 중지
return () => {
stopMike();
};
}, []);
// 비디오 토글
const handleToggleVideo = useCallback(() => {
// 1. Redux 상태 먼저 업데이트
toggleMyVideo();
// 2. 현재 상태에 따라 카메라 제어
if (isVideoOn) {
stopCamera(); // 현재 켜져있으면 끄기
} else {
startCamera(); // 현재 꺼져있으면 켜기
}
}, [isVideoOn, toggleMyVideo, startCamera, stopCamera, cameraStream]);
// 마이크 토글
const handleToggleMic = useCallback(() => {
// 1. Redux 상태 업데이트
toggleMyMic();
// 2. 현재 상태에 따라 마이크 제어
if (isMicOn) {
stopMike(); // 켜져 있으면 끄기
} else {
startMike(); // 꺼져 있으면 켜기
}
}, [isMicOn, toggleMyMic, startMike, stopMike]);
return {
cameraStream,
startCamera,
stopCamera,
handleToggleVideo,
handleToggleMic,
mikeStream,
startMike,
stopMike,
};
};
다음과 같이 이제 음성 감지 기능을 추가하였고 그 결과

image.png
마이크 기능이 활성화가 제대로 된 것을 확인할 수 있었다!
마이크 완료..
카메라 기능을 미리 했었기 때문에 같은 mediaStream 인 마이크 또한 금방 구현할 수 있었고 이제 화면 공유 기능을 구현하면 될 것 같다!
