소켓 오디오(음성) 통신-리액트 네이티브 expo

고민경·2024년 5월 25일

리액트 네이티브(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의 내용을 확인하기 위해 다음과 같이 세팅했다.

  • 'Unit8Array'로 변환하여 로그 출력
  • 'DataView'를 사용하여 로그 출력
  • 'TextDecoder'로 문자열 디코딩하여 로그 출력
 	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);
    }
  • DataView, textDecoder로 했을 때 콘솔 값
  • Unit8Array 콘솔 값(너무 길어서 일부만 캡처함)

    테스트했을 때 응답을 unit8Array로 바꾸고 출력하니 그 값을 확인할 수 있었다!!! Unit8ArrayArrayBuffer의 바이트 데이터를 다루기 위한 뷰(view)이다. Unit8Array를 통해 각 바이트에 개별적으로 접근할 수 있었다.
  • event.dataArrayBuffer이고 이를 Unit8Array 생성자에 전달하여 새로운 Unit8Array 인스턴스를 만든다.
  • Unit8ArrayArrayBuffer의 바이트 데이터를 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로 인코딩
};
  1. let binary = "";
    빈 문자열 binary를 선언한다. 이 문자열에 ArrayBuffer의 각 바이트를 문자열로 변환한 값을 누적한다.
  2. const bytes = new Uint8Array(buffer) ArrayBuffer를 Uint8Array로 변환한다. Uint8Array는 ArrayBuffer의 각 바이트에 접근할 수 있게 해준다.
  3. const len = bytes.byteLength;
    Uint8Array의 길이를 가져온다. 이는 ArrayBuffer의 바이트 수를 의미한다.
  4. for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); }
    반복문을 통해 각 바이트를 순회하며, String.fromCharCode를 사용해 각 바이트를 문자로 변환하고 이를 binary 문자열에 추가한다.
  5. 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;
};
  1. const fileUri = ${FileSystem.cacheDirectory}audio.mp3;
    파일을 저장할 경로를 지정한다. FileSystem.cacheDirectory는 앱의 캐시 디렉터리를 나타내며, 그 아래에 audio.mp3라는 이름으로 파일을 저장한다.
  2. await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 });
    FileSystem.writeAsStringAsync 함수를 사용하여 base64 문자열을 파일로 저장한다.
  • fileUri는 파일이 저장될 경로이다.
  • base64는 저장할 base64 문자열이다.
  • { encoding: FileSystem.EncodingType.Base64 }는 저장할 문자열의 인코딩 타입을 지정한다. 여기서는 base64 인코딩으로 저장한다.
  1. 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,
  },
});

참고
📁 Base64 / Blob / ArrayBuffer / File 다루기 총정리

0개의 댓글