오디오 스트림 트러블슈팅

dongmin·2024년 10월 6일
1

Trouble Shooting

목록 보기
5/6
post-thumbnail
post-custom-banner

파싱에러

문제요약

  • 서버 단에서 텍스트와 오디오를 스트림 방식으로 응답받아서 클라이언트 단에서 응답받는대로 실시간 스트리밍 해주는 기능 구현도중 문제발생
  • 텍스트와 오디오 chunk 를 동시에 보내주기 때문에 data: 를 기준으로 파싱처리를 하였지만 파싱 과정에서 문제 발생

해결과정

  • 로깅을 통해 문제 발견

  • 파싱 에러가 난 지점의 공통점 발견 → 1개의 청크 안에 여러개의 data: 가 있음

해결방법

스트림이 여러 줄에 걸쳐서 올 때 파싱 오류가 발생하는 문제를 해결하기 위해, 코드에서 부분 메시지를 병합하고, 데이터가 완전히 올 때까지 기다렸다가 처리하는 방식으로 해결

코드

  • 해결 전
const handleSendChat = async () => {
    if (chat_id === null || chatContent.trim() === "") {
      console.error("Invalid chat ID or empty content");
      return;
    }

    onNewMessage({ content: chatContent, isUser: true });
    setChatContent("");

    if (contentEditableRef.current) {
      contentEditableRef.current.innerText = "";
    }

    try {
      const response = await sendChat(chat_id, chatContent);

      if (!response || !response.body) {
        console.error("No response body");
        return;
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder("utf-8");
      let accumulatedMessage = "";
      let audioDataChunks = [];
      let done = false;
      let partialChunk = "";

      while (!done) {
        const { value, done: doneReading } = await reader.read();
        done = doneReading;

        const chunk = decoder.decode(value, { stream: true });
        console.log(chunk);
        partialChunk += chunk;

        const lines = partialChunk
          .split("\n")
          .filter((line) => line.trim() !== "");

        if (!done) {
          partialChunk = lines.pop() || "";
        } else {
          partialChunk = "";
        }

        for (const line of lines) {
          if (line.startsWith("data: ")) {
            try {
              const data = JSON.parse(line.substring(6));
              console.log(data);

              if (data.message) {
                accumulatedMessage += data.message;
                onUpdateResponse(accumulatedMessage);
              }

              if (data.audio) {
                const binaryData = hexToBinary(data.audio);
                audioDataChunks.push(binaryData);
              }
            } catch (error) {
              console.error("Error parsing JSON:", error, line);
            }
          }
        }
      }

      // 스트림이 끝난 후 최종 메시지를 추가하고, 현재 응답 초기화
      onNewMessage({ content: accumulatedMessage, isUser: false });
      setAudioData(audioDataChunks);
      onUpdateResponse("");
    } catch (error) {
      console.error("Error sending chat or receiving stream", error);
    }
  };

  const hexToBinary = (hex) => {
    const bytes = new Uint8Array(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2) {
      bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
    }
    return bytes;
  };
  • 해결 후
const handleSendChat = async () => {
    if (chat_id === null || chatContent.trim() === "") {
      console.error("Invalid chat ID or empty content");
      return;
    }

    onNewMessage({ content: chatContent, isUser: true });
    setChatContent("");

    if (contentEditableRef.current) {
      contentEditableRef.current.innerText = "";
    }

    try {
      const response = await sendChat(chat_id, chatContent);

      if (!response || !response.body) {
        console.error("No response body");
        return;
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder("utf-8");
      let accumulatedMessage = "";
      let audioDataChunks = [];
      let done = false;
      let partialChunk = "";

      while (!done) {
        const { value, done: doneReading } = await reader.read();
        done = doneReading;

        const chunk = decoder.decode(value, { stream: true });
        partialChunk += chunk;

        const lines = partialChunk.split("\n");
        if (!done) {
          partialChunk = lines.pop() || "";
        } else {
          partialChunk = "";
        }

        for (const line of lines) {
          if (line.startsWith("data: ")) {
            const jsonPart = line.substring(6).trim();
            if (jsonPart.length > 0) {
              try {
                const data = JSON.parse(jsonPart);
                console.log(data);

                if (data.message) {
                  accumulatedMessage += data.message;
                  onUpdateResponse(accumulatedMessage);
                }

                if (data.audio) {
                  const binaryData = hexToBinary(data.audio);
                  audioDataChunks.push(binaryData);
                }
              } catch (error) {
                console.error("Error parsing JSON:", error, jsonPart);
              }
            }
          }
        }
      }

      // 스트림이 끝난 후 최종 메시지를 추가하고, 현재 응답 초기화
      onNewMessage({ content: accumulatedMessage, isUser: false });
      setAudioData(audioDataChunks);
      onUpdateResponse("");
    } catch (error) {
      console.error("Error sending chat or receiving stream", error);
    }
  };

const hexToBinary = (hex) => {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
  }
  return bytes;
};
  • 변경내용
    1. partialChunk는 각 스트림 조각(chunk)이 추가될 때마다 업데이트한다. 이는 조각이 여러 줄로 나뉘어 있을 수 있기 때문이다.
    2. partialChunk.split("\n")로 각 줄을 나누고, 마지막 줄이 완전하지 않을 경우(done이 아닐 경우) 이를 partialChunk로 다시 설정한다.
    3. 각 줄을 처리할 때, data: 로 시작하는 줄만 처리하도록 하여 정확히 데이터를 파싱한다.

오디오 스트리밍 마지막 청크 누락 이슈

if (data.audio) {
                  const binaryData = hexToBinary(data.audio);
                  console.log("Received audio chunk:", binaryData);
                  setAudioData((prevData: Uint8Array[]) => [
                    ...(prevData || []),
                    binaryData,
                  ]);
                }
useEffect(() => {
    console.log("Received audio data:", audioData);

    if (mediaSourceOpened.current && audioData.length > 0) {
      const newAudioData = audioData.filter((data) => {
        const dataString = Array.from(data).join(",");
        if (!audioSet.has(dataString)) {
          audioSet.add(dataString);
          return true;
        }
        return false;
      });

      queueRef.current.push(...newAudioData);
      appendToBuffer();
    }
  }, [audioData]);

각 청크의 데이터와 실제 오디오플레이어 컴포넌트에 전달되고 있는 오디오 데이터를 로그로 찍어서 비교해보았다.

오디오플레이어 컴포넌트에 찍힌 데이터는 82개 이다.

하지만 실제 응답받은 오디오 데이터 청크는 31개였다.

그렇다면 오디오데이터가 초기화 되지 않아서 데이터가 누락된것일까..

setAudioData(() => []);

위의 코드를 추가하여 새로 스트림 응답을 요청할 때 초기화 하도록 해주고 다시 로그를 보았다.

오디오 플레이어 컴포넌트에 25개의 데이터가 도착했다.

실제 응답받은 오디오 청크도 25개 이다.

오디오데이터 초기화 문제는 아닌듯 하다..

서버에서 받은 데이터를 모두 합친 뒤 재생하면 끝까지 정상적으로 작동하는 것을 보면 스트림데이터 자체에는 문제가 없어보이는데 뭐가 문제일까..

청크데이터를 자세히 보니 마지막 오디오 청크의 형식이 다른 청크의 데이터와 다르게 aaaaaaa반복되는 문자열이 빠져있는 것을 확인하고 백엔드 동료에게 전달하고 백엔드 로직을 수정하여 문제가 해결되었다.

profile
아이스박스
post-custom-banner

0개의 댓글