리액트 네이티브(expo) 프로젝트에서 영어 회화를 구현해야 했다. 웹소켓 통신 응답으로 오디오를 주고, 사용자가 녹음을 한 후에 이 녹음 파일을 base64로 인코딩해서 send하고 이를 반복하면서 회화하는 방식이었다.
소켓 연결을 하고 나면 바로 오디오 응답이 오는데, 이를 리액트 네이티브에서 재생하는 데 어려움을 겪었다. postman으로 테스트했을 때 응답이 audio/mpeg으로 왔는데, 로컬에서는 이 응답이 어떤 형태로 오는지 파악하기 어려워서 어떻게 재생해야 할지 엄청 고민했다.

일단 소켓 응답의 type이 무엇인지 알아보고자 다음과 같이 코드를 작성해서 테스트했다.
import React, { useEffect, useState, useRef } from "react";
import { View, Text, Button, FlatList, StyleSheet } from "react-native";
import { Audio } from "expo-av";
import * as FileSystem from "expo-file-system";=
const VoiceChat = () => {
const [audio, setAudio] = useState(null);
const ws = useRef(null);
const soundRef = useRef(new Audio.Sound());
useEffect(() => {
ws.current = new WebSocket(
"{소켓 연결 api}"
);
ws.current.onopen = () => {
console.log("WebSocket connected");
};
ws.current.onmessage = async (event) => {
if (typeof event.data === "string") {
// 데이터가 string인지 확인
console.log("Receive text message:", event.data);
} else if (event.data instanceof Blob) {
// 데이터가 Blob인지 확인
console.log("Receive Blob message:", event.data);
} else if (event.data instanceof ArrayBuffer) {
// 데이터가 ArrayBuffer인지 확인
console.log("Received ArrayBuffer message:", event.data);
} else {
// 다른 유형의 데이터
console.log("Receive message of unknown type:", event.data);
}
};
ws.current.onerror = (error) => {
console.log("WebSocket error:", error);
};
ws.current.onclose = () => {
setIsConnected(false);
console.log("WebSocket connection closed");
};
return () => {
if (ws.current) {
ws.current.close();
}
};
}, []);
콘솔을 확인해보니
ArrayBuffer임을 확인할 수 있었다. 즉 오디오 바이트 스트림(audio byte stream)이 arraybuffer의 형태로 오는 것이었다. 근데 콘솔에 event.data를 출력했을 때는 빈 배열이 찍혀 있어서 응답이 null인 건지 아님 처리가 더 필요한지 파악하기 힘들었다.
ArrayBuffer의 내용을 파악하는 것이 우선일 것 같았다. ArrayBuffer의 내용을 확인하기 위해 다음과 같이 세팅했다.
ws.current.onmessage = async (event) => {
if (event.data instanceof ArrayBuffer) {
console.log("Received ArrayBuffer message:", event.data);
// ArrayBuffer를 확인하는 코드
const uint8Array = new Uint8Array(event.data);
console.log("Uint8Array:", uint8Array);
const dataView = new DataView(event.data);
console.log("DataView:", dataView);
const textDecoder = new TextDecoder("utf-8");
const decodedString = textDecoder.decode(uint8Array);
console.log("Decoded String:", decodedString);
await playAudio(uri);
} else {
console.log("Receive message of unknown type:", event.data);
}


unit8Array로 바꾸고 출력하니 그 값을 확인할 수 있었다!!! Unit8Array는 ArrayBuffer의 바이트 데이터를 다루기 위한 뷰(view)이다. Unit8Array를 통해 각 바이트에 개별적으로 접근할 수 있었다. event.data는 ArrayBuffer이고 이를 Unit8Array 생성자에 전달하여 새로운 Unit8Array 인스턴스를 만든다. Unit8Array는 ArrayBuffer의 바이트 데이터를 8비트 부호 업는 정수(unsigned byte)배열로 나타낸다. 이 arrayBuffer를 base64로 인코딩하고, 인코딩한 base64 문자열을 오디오 파일로 변환했다.
arrayBufferToBase64함수: 소켓 응답으로 받아온ArrayBuffer를 base64 문자열로 변환한다. 이를 통해 이진 데이터를 문자열로 인코딩하여 저장하거나 전송할 수 있다.const arrayBufferToBase64 = (buffer) => { let binary = ""; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); // binary 문자열을 base64로 인코딩 };
- let binary = "";
빈 문자열 binary를 선언한다. 이 문자열에 ArrayBuffer의 각 바이트를 문자열로 변환한 값을 누적한다.- const bytes = new Uint8Array(buffer) ArrayBuffer를 Uint8Array로 변환한다. Uint8Array는 ArrayBuffer의 각 바이트에 접근할 수 있게 해준다.
- const len = bytes.byteLength;
Uint8Array의 길이를 가져온다. 이는 ArrayBuffer의 바이트 수를 의미한다.- for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); }
반복문을 통해 각 바이트를 순회하며, String.fromCharCode를 사용해 각 바이트를 문자로 변환하고 이를 binary 문자열에 추가한다.- return btoa(binary);
최종적으로 binary 문자열을 base64로 인코딩하여 반환한다. btoa 함수는 바이너리 데이터를 base64로 인코딩한다.
saveBase64AsAudioFile함수: 이 함수는 base64 문자열을 오디오 파일로 저장한다. 나는 expo를 사용하고 있으므로expo-file-system라이브러리를 사용하여 파일 시스템에 저장한다.const saveBase64AsAudioFile = async (base64) => { const fileUri = `${FileSystem.cacheDirectory}audio.mp3`; await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64, }); return fileUri; };
- const fileUri = ${FileSystem.cacheDirectory}audio.mp3;
파일을 저장할 경로를 지정한다. FileSystem.cacheDirectory는 앱의 캐시 디렉터리를 나타내며, 그 아래에 audio.mp3라는 이름으로 파일을 저장한다.- await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 });
FileSystem.writeAsStringAsync 함수를 사용하여 base64 문자열을 파일로 저장한다.
- fileUri는 파일이 저장될 경로이다.
- base64는 저장할 base64 문자열이다.
- { encoding: FileSystem.EncodingType.Base64 }는 저장할 문자열의 인코딩 타입을 지정한다. 여기서는 base64 인코딩으로 저장한다.
- return fileUri;
저장된 파일의 URI를 반환한다. 이 URI는 나중에 파일을 재생하거나 접근할 때 사용된다.
위와 같이 작성하니 정상적으로 음성(오디오)를 재생할 수 있었다!!
import React, { useEffect, useState, useRef } from "react";
import { View, Button, StyleSheet } from "react-native";
import { Audio } from "expo-av";
import * as FileSystem from "expo-file-system";
const VoiceChat = () => {
const [audio, setAudio] = useState(null);
const ws = useRef(null);
const soundRef = useRef(new Audio.Sound());
useEffect(() => {
ws.current = new WebSocket(
"ws://34.22.72.154:12300/api/conversation/websocket/110ec58a-a0f2-4ac4-8393-c866d813b8d1?conversation_id=1"
);
ws.current.onopen = () => {
setIsConnected(true);
console.log("WebSocket connected");
};
ws.current.onmessage = async (event) => {
if (event.data instanceof ArrayBuffer) {
// ArrayBuffer로부터 오디오 데이터를 읽고 처리
console.log("Received ArrayBuffer message:", event.data);
// ArrayBuffer를 확인하는 코드
const uint8Array = new Uint8Array(event.data);
console.log("Uint8Array:", uint8Array);
// ArrayBuffer를 base64로 변환
const base64Audio = arrayBufferToBase64(event.data);
const uri = await saveBase64AsAudioFile(base64Audio);
setAudio(uri);
await playAudio(uri);
} else {
// 다른 유형의 데이터
console.log("Receive message of unknown type:", event.data);
}
};
ws.current.onerror = (error) => {
console.log("WebSocket error:", error);
};
ws.current.onclose = () => {
setIsConnected(false);
console.log("WebSocket connection closed");
};
return () => {
if (ws.current) {
ws.current.close();
}
};
}, []);
const arrayBufferToBase64 = (buffer) => {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
const saveBase64AsAudioFile = async (base64) => {
const fileUri = `${FileSystem.cacheDirectory}audio.mp3`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
return fileUri;
};
const playAudio = async (uri) => {
try {
await soundRef.current.unloadAsync();
await soundRef.current.loadAsync({ uri });
await soundRef.current.playAsync();
} catch (error) {
console.error("Error playing audio:", error);
}
};
return (
<View style={styles.container}>
<Button title="Play" onPress={() => playAudio(audio)} />
{/* 버튼 누르면 소켓에서 보낸 응답 들을 수 있음 */}
</View>
);
};
export default VoiceChat;
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
});