background
aurora5

마이크 연결

2025년 12월 9일

다시 시작이다!

요즘 취준도 하고 이력서, 자소서도 넣고, 서버 이관으로 인해 aurora 프로젝트가 잠시 중단되었다가 저번주에 다시 시작되었다. 그에 따라서 한 주동안 내가 작성했던 코드를 다시 살펴보며, 자잘한 변경이 있었는데 우선적으로 서버장은 서버 멤버의 권한을 변경할 수 있는 권한을 주었고 기존에 서버에서의 활성화 여부를 확인하기 위한 status 값을 프로젝트와 채널과 구분할 수 있도록 s_status 값으로 변경 해주었다. 그렇게 서버, 프로젝트 로직은 어느정도 완성이 되어서 이제 메인 기능인 mediaStream 도입을 하기로 했다.

마이크 먼저..

우선, 카메라는 기존에 구현을 해놨기에 카메라의 경우를 살펴보며 마이크 설정을 구현하게 되었다.

기초가 되는 마이크 관련 훅을 작성해주고 redux에 연결 UI 에 반영하는 것이 목표였다.

useMike()

typescript
export 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()

typescript
import { 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

typescript
import { 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

typescript
import { 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

image.png

이렇게 권한을 받게 된다. 근데 여기서 문제가 발생하는데, 지금 당장 스피커 기능도 없기 때문에 내가 말하고 있는 지 확인할 방법이 없었다. 그래서 음성 감지 로직도 추가하게 되었는데

방법은 AudioContext 에 현재 stream 을 주입하고 AnalyserNode 로 현재 마이크의 볼륨을 체크하여 값이 변경되면 isSpeaking 값을 변경해주어 음성 감지가 되도록 하였다.

useMike()

typescript
import { 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()

typescript
import { 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

image.png

마이크 기능이 활성화가 제대로 된 것을 확인할 수 있었다!

마이크 완료..

카메라 기능을 미리 했었기 때문에 같은 mediaStream 인 마이크 또한 금방 구현할 수 있었고 이제 화면 공유 기능을 구현하면 될 것 같다!

태그

#aurora