From 63d6959cab83fd332cefaf675b2fcfe71564af1b Mon Sep 17 00:00:00 2001 From: PARK NA HYUN <116629752+studioOwol@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:40:31 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=8A=94=20?= =?UTF-8?q?=EA=B2=8C=EC=9E=84=EB=B0=A9=EC=97=90=20=EC=9E=85=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=9D=8C?= =?UTF-8?q?=EC=84=B1=20=ED=86=B5=ED=99=94=EB=A5=BC=20=ED=95=A0=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EB=8B=A4.=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 마이크 권한 요청 기능 구현 * feat: WebRTC 실시간 음성 통화 기능 구현 * chore: 시그널링 서버 URL 변경 --- fe/src/services/audioRequest.ts | 60 +++++++ fe/src/services/gameSocket.ts | 113 ------------- fe/src/services/socket.ts | 271 ++++++++++++++++++++++++++++++++ fe/src/store/useRoomStore.ts | 2 +- fe/src/types/audioTypes.ts | 5 + 5 files changed, 337 insertions(+), 114 deletions(-) create mode 100644 fe/src/services/audioRequest.ts delete mode 100644 fe/src/services/gameSocket.ts create mode 100644 fe/src/services/socket.ts create mode 100644 fe/src/types/audioTypes.ts diff --git a/fe/src/services/audioRequest.ts b/fe/src/services/audioRequest.ts new file mode 100644 index 0000000..feeabd4 --- /dev/null +++ b/fe/src/services/audioRequest.ts @@ -0,0 +1,60 @@ +import { AudioStreamSetup } from '@/types/audioTypes'; + +export const requestAudioStream = async (): Promise => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + sampleRate: 48000, + channelCount: 1, + }, + video: false, + }); + return stream; + } catch (error) { + console.error('Error accessing microphone:', error); + if (error instanceof DOMException) { + if (error.name === 'NotAllowedError') { + throw new Error( + '마이크 사용 권한이 거부되었습니다. 권한을 허용해주세요.' + ); + } else if (error.name === 'NotFoundError') { + throw new Error( + '마이크를 찾을 수 없습니다. 마이크가 연결되어 있는지 확인해주세요.' + ); + } + } + throw new Error('마이크 접근에 실패했습니다.'); + } +}; + +export const cleanupAudioStream = (stream: MediaStream) => { + stream.getTracks().forEach((track) => track.stop()); +}; + +export const setupAudioStream = async ( + stream: MediaStream +): Promise => { + try { + const audioContext = new AudioContext(); + const source = audioContext.createMediaStreamSource(stream); + const gainNode = audioContext.createGain(); + + source.connect(gainNode); + gainNode.connect(audioContext.destination); + + // 기본 볼륨 설정 + gainNode.gain.value = 0.5; + + return { + audioContext, + source, + gainNode, + }; + } catch (error) { + console.error('Error setting up audio stream:', error); + throw error; + } +}; diff --git a/fe/src/services/gameSocket.ts b/fe/src/services/gameSocket.ts deleted file mode 100644 index 4dcd473..0000000 --- a/fe/src/services/gameSocket.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { JoinGameRoomResult, Room } from '@/types/roomTypes'; -import { - ClientToServerEvents, - ServerToClientEvents, -} from '@/types/socketTypes'; -import { io, Socket } from 'socket.io-client'; - -const SOCKET_BASE_URL = 'wss://game.clovapatra.com'; - -const gameSocket: Socket = io( - `${SOCKET_BASE_URL}/rooms`, - { - transports: ['websocket'], - withCredentials: true, - } -); - -// 소켓 연결 상태 모니터링 -gameSocket.on('connect', () => { - console.log('Socket connected'); -}); - -gameSocket.on('connect_error', (error) => { - console.error('Socket connection error:', error); -}); - -gameSocket.on('disconnect', (reason) => { - console.log('Socket disconnected:', reason); -}); - -export const createRoom = async ( - roomName: string, - hostNickname: string -): Promise => { - return new Promise((resolve, reject) => { - gameSocket.emit('createRoom', { roomName, hostNickname }); - - gameSocket.on('roomCreated', (room) => { - resolve(room); - }); - - gameSocket.on('error', (error) => { - reject(error); - }); - }); -}; - -export const joinRoom = async ( - roomId: string, - playerNickname: string -): Promise => { - if (!gameSocket.connected) { - throw new Error('서버와 연결이 되지 않았습니다.'); - } - - console.log('Attempting to join room:', { roomId, playerNickname }); - - return new Promise((resolve, reject) => { - let isResolved = false; - - // updateUsers 이벤트 핸들러 - const handleUpdateUsers = (players: string[]) => { - console.log('Received updateUsers:', players); - - // 첫 업데이트에서만 resolve 하도록 - if (!isResolved) { - isResolved = true; - cleanup(); - - // Room 객체 구성 - const room: Room = { - roomId, - roomName: `Room ${roomId}`, // 서버에서 따로 제공하지 않음 - hostNickname: players[0], // 첫 번째 플레이어를 호스트로 가정 - players: players, - status: 'waiting', - }; - - resolve({ - room, - stream: new MediaStream(), - }); - } - }; - - const handleError = (error: { code: string; message: string }) => { - console.error('Error joining room:', error); - cleanup(); - reject(error); - }; - - const cleanup = () => { - gameSocket.off('updateUsers', handleUpdateUsers); - gameSocket.off('error', handleError); - clearTimeout(timeoutId); - }; - - // 이벤트 리스너 등록 - gameSocket.on('updateUsers', handleUpdateUsers); - gameSocket.on('error', handleError); - - // 방 입장 요청 - gameSocket.emit('joinRoom', { roomId, playerNickname }); - - // 타임아웃 설정 - const timeoutId = setTimeout(() => { - if (!isResolved) { - cleanup(); - reject(new Error('서버 응답 시간이 초과되었습니다.')); - } - }, 5000); - }); -}; diff --git a/fe/src/services/socket.ts b/fe/src/services/socket.ts new file mode 100644 index 0000000..7133bfd --- /dev/null +++ b/fe/src/services/socket.ts @@ -0,0 +1,271 @@ +import { io, Socket } from 'socket.io-client'; +import { requestAudioStream, cleanupAudioStream } from './audioRequest'; +import { JoinGameRoomResult, Room } from '@/types/roomTypes'; +import { + ClientToServerEvents, + ServerToClientEvents, + SignalingServerToClientEvents, + SignalingClientToServerEvents, +} from '@/types/socketTypes'; + +const SOCKET_BASE_URL = 'wss://game.clovapatra.com'; +const SIGNALING_URL = 'https://signaling.clovapatra.com'; + +// 게임 서버 소켓 +const gameSocket: Socket = io( + `${SOCKET_BASE_URL}/rooms`, + { + transports: ['websocket'], + withCredentials: true, + } +); + +// 시그널링 서버 소켓 +const signalingSocket: Socket< + SignalingServerToClientEvents, + SignalingClientToServerEvents +> = io(SIGNALING_URL, { + transports: ['websocket'], + withCredentials: true, +}); + +// WebRTC 설정 +const configuration: RTCConfiguration = { + iceServers: [ + { + urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'], + }, + { + urls: 'turn:turn.anyfirewall.com:443?transport=tcp', + credential: 'webrtc', + username: 'webrtc', + }, + ], + // iceCandidatePoolSize가 필요한 경우에만 추가 + iceCandidatePoolSize: 0, + // RTCConfiguration에 있는 옵션들만 사용 + bundlePolicy: 'max-bundle', + rtcpMuxPolicy: 'require', + // iceTransportPolicy는 'all' | 'relay' 중 하나만 가능 + iceTransportPolicy: 'all', +}; + +let peerConnections: Record = {}; +let localStream: MediaStream | null = null; +let currentRoomId: string | null = null; +let currentUserId: string | null = null; + +export const createRoom = async ( + roomName: string, + hostNickname: string +): Promise => { + try { + // 1. 오디오 스트림 요청 + const stream = await requestAudioStream(); + localStream = stream; + + return new Promise((resolve, reject) => { + // 2. 방 생성 요청 + gameSocket.emit('createRoom', { roomName, hostNickname }); + + const handleRoomCreated = (room: Room) => { + currentRoomId = room.roomId; + currentUserId = hostNickname; + + // 3. 시그널링 서버 조인 + signalingSocket.emit('join', { + roomId: room.roomId, + userId: hostNickname, + }); + + cleanup(); + resolve(room); + }; + + const handleError = (error: { code: string; message: string }) => { + cleanup(); + cleanupAudioStream(stream); + reject(error); + }; + + const cleanup = () => { + gameSocket.off('roomCreated', handleRoomCreated); + gameSocket.off('error', handleError); + }; + + gameSocket.on('roomCreated', handleRoomCreated); + gameSocket.on('error', handleError); + }); + } catch (error) { + console.error('방 생성 실패:', error); + throw error; + } +}; + +export const joinRoom = async ( + roomId: string, + playerNickname: string +): Promise => { + try { + // 1. 오디오 스트림 요청 + const stream = await requestAudioStream(); + localStream = stream; + + return new Promise((resolve, reject) => { + let isResolved = false; + + const handleUpdateUsers = (players: string[]) => { + if (!isResolved) { + isResolved = true; + cleanup(); + + const room: Room = { + roomId, + roomName: `Room ${roomId}`, + hostNickname: players[0], + players: players, + status: 'waiting', + }; + + currentRoomId = roomId; + currentUserId = playerNickname; + + // 시그널링 서버 조인 + signalingSocket.emit('join', { + roomId: roomId, + userId: playerNickname, + }); + + resolve({ + room, + stream, + }); + } + }; + + const handleError = (error: { code: string; message: string }) => { + cleanup(); + cleanupAudioStream(stream); + reject(error); + }; + + const cleanup = () => { + gameSocket.off('updateUsers', handleUpdateUsers); + gameSocket.off('error', handleError); + }; + + gameSocket.on('updateUsers', handleUpdateUsers); + gameSocket.on('error', handleError); + + gameSocket.emit('joinRoom', { roomId, playerNickname }); + }); + } catch (error) { + console.error('방 참가 실패:', error); + throw error; + } +}; + +// WebRTC 관련 함수들 +async function createPeerConnection( + peerId: string +): Promise { + try { + const pc = new RTCPeerConnection(configuration); + + pc.onicecandidate = (event) => { + if (event.candidate) { + signalingSocket.emit('ice-candidate', { + targetId: peerId, + candidate: event.candidate, + }); + } + }; + + pc.ontrack = (event) => { + const audioElement = new Audio(); + audioElement.autoplay = true; + audioElement.srcObject = event.streams[0]; + document.body.appendChild(audioElement); + }; + + if (localStream) { + localStream.getTracks().forEach((track) => { + pc.addTrack(track, localStream!); + }); + } + + peerConnections[peerId] = pc; + return pc; + } catch (error) { + console.error('Error creating peer connection:', error); + throw error; + } +} + +// 시그널링 이벤트 핸들러 설정 +signalingSocket.on('joinSuccess', () => { + console.log('Successfully joined signaling room'); +}); + +signalingSocket.on('peer-joined', async ({ peerId, userId }) => { + console.log('New peer joined:', { peerId, userId }); + if (currentUserId && currentUserId < userId) { + const pc = await createPeerConnection(peerId); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + signalingSocket.emit('offer', { + targetId: peerId, + sdp: pc.localDescription!, + }); + } +}); + +signalingSocket.on('offer', async ({ targetId, sdp }) => { + const pc = await createPeerConnection(targetId); + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + signalingSocket.emit('answer', { + targetId: targetId, + sdp: pc.localDescription!, + }); +}); + +signalingSocket.on('answer', async ({ targetId, sdp }) => { + const pc = peerConnections[targetId]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + } +}); + +signalingSocket.on('ice-candidate', async ({ targetId, candidate }) => { + const pc = peerConnections[targetId]; + if (pc) { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } +}); + +signalingSocket.on('peer-left', ({ peerId }) => { + const pc = peerConnections[peerId]; + if (pc) { + pc.close(); + delete peerConnections[peerId]; + } +}); + +// 소켓 연결 상태 모니터링 +gameSocket.on('connect', () => { + console.log('Game socket connected'); +}); + +signalingSocket.on('connect', () => { + console.log('Signaling socket connected'); +}); + +gameSocket.on('connect_error', (error) => { + console.error('Game socket connection error:', error); +}); + +signalingSocket.on('connect_error', (error) => { + console.error('Signaling socket connection error:', error); +}); diff --git a/fe/src/store/useRoomStore.ts b/fe/src/store/useRoomStore.ts index 17bab64..0789cc3 100644 --- a/fe/src/store/useRoomStore.ts +++ b/fe/src/store/useRoomStore.ts @@ -1,4 +1,4 @@ -import { createRoom, joinRoom } from '@/services/gameSocket'; +import { createRoom, joinRoom } from '@/services/socket'; import { RoomStore } from '@/types/roomTypes'; import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; diff --git a/fe/src/types/audioTypes.ts b/fe/src/types/audioTypes.ts new file mode 100644 index 0000000..ca5ae77 --- /dev/null +++ b/fe/src/types/audioTypes.ts @@ -0,0 +1,5 @@ +export interface AudioStreamSetup { + audioContext: AudioContext; + source: MediaStreamAudioSourceNode; + gainNode: GainNode; +}