앞서 여태까지의 기능들을 모두 한 컴포넌트에 모았는데, 그것들을 정리해보려한다.
우선 useSocket.js 파일을 생성한다
// useSocket.js
import Peer from "peerjs";
import React, { useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";
import audioFrequency from "../audioFrequency";
import audioContext from "../audioContext";
var getUserMedia =
navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozG;
const useSocket = ({
myVideoRef,
// connectToNewUser,
setstreamsFunc,
setMgsFunc,
roomid,
setFilterstreamFunc,
}) => {
const socketRef = useRef(null);
const peerRef = useRef(null);
const usersRef = useRef({});
useEffect(() => {
socketRef.current = io.connect("http://localhost:9000");
navigator.mediaDevices
.getUserMedia({ audio: true, video: true })
.then((stream) => {
//내 스트림데이터(비디오, 오디오)를 가져와서 비디오 태그에 연결
myVideoRef.current.srcObject = stream;
socketRef.current.on("user-connected", (id, username) => {
//새 유저 접속 시 기존 유저는 user-connected메세지를 받음
connectToNewUser(id, stream, username);
//나 자신(기존유저)와 새 유저의 피어 연결
});
socketRef.current.on("user-disconnected", (id) => {
// const new_streams = streams.filter((st) => st.id !== id);
// setStreams(new_streams);
delete usersRef.current[id];
setFilterstreamFunc(id);
});
socketRef.current.on("other-user-streaming-start", (id) => {
connectToNewUser(id, stream, "username");
});
});
socketRef.current.on("receive-message", (ms, id) => {
//sender를 포함한 룸 유저 전체에게 메세지
setMgsFunc(ms, id);
// setMsg((p) => [...p, { message: ms, id }]);
});
// 피어생성;
peerRef.current = new Peer();
peerRef.current.on("call", (call) => {
//기존 유저에게 전화걸기
console.log("call = ", call);
getUserMedia(
//전화를 걸어버림
{ video: true, audio: true },
function (stream) {
call.answer(stream);
call.on("stream", function (remoteStream) {
if (usersRef.current[call.peer]) {
return;
}
usersRef.current[call.peer] = remoteStream;
//기존에 잇던 사람듸 스트림을 받아옴
// const new_stream = [
// ...streams,
// { stream: remoteStream, id: call.peer },
// ];
setstreamsFunc({
called: "peer on call",
stream: remoteStream,
id: call.peer,
});
// setStreams((p) => [...p, { stream: remoteStream, id: call.peer }]);
});
},
function (err) {
alert(err);
}
);
});
peerRef.current.on("open", (id) => {
//피어 생성하면 기본적으로 실행됨
console.log("내 피어 id = ", id);
socketRef.current.emit("join-room", roomid, id);
});
return () => {
socketRef.current.off("receive-message");
socketRef.current.off("other-user-streaming-start");
socketRef.current.off('"user-disconnected');
socketRef.current.off("user-connected");
};
}, []);
function connectToNewUser(userId, stream, username) {
//전화 받기
const call = peerRef.current.call(userId, stream);
call.on("stream", (userVideoStream) => {
//새로 접속한 유저의 스트림을 얻어옴
const userIbj = {
stream: userVideoStream,
id: userId,
};
if (usersRef.current[userId]) return;
usersRef.current[userId] = stream;
setstreamsFunc(userIbj);
});
call.on("close", () => {
//접속 끊김
setFilterstreamFunc(call.peer);
// 비디오 삭제
});
}
return { socketRef, peerRef, vol };
};
export default useSocket;
마찬가지로 stream.js와 chat.js를 생성한다.
// Stream.js
import Peer from "peerjs";
import React, { useRef } from "react";
const displayMediaOptions = {
video: {
cursor: "always",
height: 500,
width: 500,
},
audio: false,
};
const Stream = ({ roomid, socketRef }) => {
const streamingPeerRef = useRef();
const streamingVideoRef = useRef(null);
const onSharingStart = async () => {
// event.stopPropagation();
try {
await navigator.mediaDevices
.getDisplayMedia(displayMediaOptions)
.then((stream) => {
streamingVideoRef.current.srcObject = stream;
streamingPeerRef.current = new Peer();
streamingPeerRef.current.on("open", (id) => {
console.log("streaming peer = ", id);
socketRef.current.emit("streaming-start", roomid, id);
});
streamingPeerRef.current.on("call", (call) => {
call.answer(stream);
});
});
} catch (err) {
// Handle error
console.error("Error: " + err);
}
};
// stop sharing
const StopSharing = () => {
streamingVideoRef.current.srcObject = null;
// if (!streamingSocketRef.current) return;
// if (!sharingVedioRef.current.srcObject) return;
// let tracks = sharingVedioRef.current.srcObject.getTracks();
// tracks.forEach((track) => track.stop());
// sharingVedioRef.current.srcObject = null;
// streamingPeerRef.current = null;
};
return (
<div>
<button onClick={onSharingStart} id="start">
SharingStart
</button>
<button onClick={StopSharing}>Stop sharing</button>
<video autoPlay id="streaming" ref={streamingVideoRef} />
</div>
);
};
export default Stream;
// Chat.js
import React, { useRef } from "react";
const Chat = ({ socketRef, roomid, msg }) => {
const inputRef = useRef(null);
const onSubmit = (e) => {
e.preventDefault();
const { value } = inputRef.current;
socketRef.current.emit("message-send", value, socketRef.current.id, roomid);
inputRef.current.value = "";
};
return (
<div id="chat">
<form onSubmit={onSubmit}>
<label htmlFor="text">텍스트 입력 </label>
<input type="text" id="text" ref={inputRef} />
</form>
<div>
{msg.map((ms, idx) => (
<div key={idx}>{ms.message}</div>
))}
</div>
</div>
);
};
export default Chat;
이제 정리는 얼추 끝이났다
이번에 넣어볼 기능은 오디오 기능이다.
디스코드에서는 해당 사용자가 말을하고 있다면 유저 아이콘에 초록색 원이 생겨난다.
이런 기능은 디스코드 뿐만 아니라 Zoom이나 Google Meet에서도 심심치않게 확인할 수 있다.
사실 이 기능을 넣는데 꽤나 애를 먹었다.
어떻게 검색을 해야 원하는 기능의 핵심을 알 수 있을까 많이 고민하기도 했고 막상 검색을 해도 많이들 시도한 기능은 아니었기 때문이다.
그러다 우연히 찾은게 MDN문서의 AudioContext이다
우선 Audio 컨텍스트부터 알아보자.
AudioContext인터페이스는 AudioNode에 의해 각각 표현되는, 함께 연결된 오디오 모듈로부터 만들어진 오디오 프로세싱 그래프를 표현합니다. 오디오 컨텍스트는 이것이 포함하는 노드의 생성과 오디오 프로세싱 혹은 디코딩의 실행 둘 다를 제어합니다. 여러분은 다른 무언가를 하기 전에 AudioContext를 생성할 필요가 있습니다. 왜냐하면 모든 것은 컨텍스트 내에서 발생하기 때문입니다. 매번 새로운 컨텍스트를 초기화하는 대신 하나의 AudioContext를 생성하고 재사용하는 것이 추천되며, 몇 개의 다른 오디오 소스에 대해 하나의 AudioContext를 사용하고 동시에 연결하는 것은 문제없습니다.
라고 MND문서에 친절하게 나와있다.
하지만 엄청 불친절하다. 당최 무슨 말인지 하나도 이해가 되질 않는다.
그냥 쉽게말해 오디오 데이터를 받아 처리 및 분석을 수행할 수 있게 만들어주는 webAPI이다.
예를들어 볼륨 조절, pause/start 등의 이벤트 또한 이 API를 사용한다
AudioContext는 크게 세개의 노드들로 나눌 수 있는데 Inputs, Effects, Destination노드들이다.
Web Audio API의 일반적인 작업 흐름은 다음과 같다.
- AudioContext를 생성한다.
- AudioContext 내에 입력 소스(Inputs)를 생성한다.(<audio> 태그, 발진기, 스트림 등)
- 오디오 효과 적용을 위한 Effects Node를 생성한다. (잔향 효과, 바이쿼드 필터, 패너, 컴프레서 등)
- 오디오의 최종 목적지(Destination)를 선택한다. (시스템 스피커 등)
- Inputs을 Effects에 연결하고, Effects를 Destination으로 연결한다.
우선 AudioContextAPI를 생성해야한다.
const audioContext = new AudioContext();
그다음 오디오의 분석을 위한 분석기를 생성한다.
const analyser = audioContext.createAnalyser();
이 analyzerNode는 AuidoContext의 createAnalyser 메소드를 사용하여, 주파수를 시각화 할 수 있는 AnalyserNode를 만들어줍니다.
오디오 컨텍스트로 분석할 수 있는 것은 이미 준비된 오디오 파일 등이 있지만 우린 stream데이터를 이용할 것이므로 다음을 생성하고 이미 생성해둔 분석기를 연결해준다.
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
그리고 이 분석기의 fftSize는 256또는 그 이상(2의 배수)로 설정해준다.
analyser.fftSize = 256;
fftSize는 주파수 영역을 결정하는데 사용되는 FFT ( 고속 푸리에 변환 )의 크기를 정의한다. 쉽게 말해 값이 높을수록 더 많은 세부 정보가 표시된다. 그러나 진폭영역에서는 세부 정보가 적어진다.
다음으로 주파수 분석을 위한 FFT의 밴드 수를 구해야한다.
const bufferLength = analyser.frequencyBinCount;
즉, 이 값은 주파수 분석의 정밀도를 결정한다. 주파수 분석을 수행할 때, 이 밴드수에 따라 주파수 스펙트럼의 크기 및 정확도가 결정된다.
다음으로는 이 오디오 스트림의 주파수 데이터를 저장하는데 사용되는 배열을 생성해야한다.
이를 8비트 부호 없는 정수 배열로 선언해야하기 때문에 Unit8Array로 만들어야한다.
const dataArray = new Uint8Array(bufferLength);
이렇게 생성된 주파수 분석 결과 배열을 이용해 오디오의 볼륨 레벨을 계산하면 끝이난다.
analyser.getByteFrequencyData(dataArray);
const vol = audioFrequency(dataArray, bufferLength);
setVol(Math.floor((vol / 256) * 100));
하지만 스트림 데이터는 실시간으로 들어오기에 setInterval함수로 이를 계속 계산해주어야한다
let analyserInterval;
analyserInterval = setInterval(() => {
analyser.getByteFrequencyData(dataArray);
const vol = audioFrequency(dataArray, bufferLength);
setVol(Math.floor((vol / 256) * 100));
}, 30);
return () => clearInterval(analyserInterval);
순서대로
analyzer.getByteFrequencyData는 analyser노드를 사용해 주파수를 분석하고 그 결과를 배열에 저장한다.
이 배열에는 각 주파수 밴드에 해당하는 오디오 신호의 크기가 저장된다.
audioFrequency는 dataArray배열과 주파수 분석의 밴드 수인 bufferLength를 사용해 오디오의 볼륨을 계산하는 함수인 audioFrequency를 호출한다. 이 함수는 분석된 주파수 데이터를 기반으로 볼륨을 계산하는데 사용된다.
종합적인 코드는 다음과 같다.
function audioVolume(stream) {
let analyserInterval;
// const { analyser, bufferLength, dataArray } = audioContext(stream); console.log(stream);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser);
analyser.fftSize = 256; // 256 ~ 2048
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
// return { analyser, bufferLength, dataArray };
analyserInterval = setInterval(() => {
analyser.getByteFrequencyData(dataArray);
const vol = audioFrequency(dataArray, bufferLength);
setVol(Math.floor((vol / 256) * 100));
}, 30);
return () => clearInterval(analyserInterval);
}
코드로 보자면 몇 줄 안되지만 이걸 만드느라 꽤나 애를 먹었다.
다행히도 MDN에서 예제가 있었기에 조금은 쉽게 할 수 있었다.