음성 파일 형식 정규화

Summary

  1. 문제: Android/iOS 기기별 음성 녹음 포맷 불일치로 인한 Whisper AI 인식 오류 발생
  2. 해결: GCF(FFmpeg) 서버 방식을 거쳐, 최종적으로 브라우저(Web Audio API) 기반 재인코딩 파이프라인 구축
  3. 성과: Web Audio API 기반의 브라우저 재인코딩 공정으로 전환하여, 서버 비용 없이 녹음본을 자동 검수할 수 있는 데이터 정규화 파이프라인을 구축

Problem

배경

  • 불특정 다수의 사람에게 녹음할 스크립트(발화문)을 제공한 뒤, 녹음된 음성의 정확도를 검사하기 위해 OpenAIWhisper를 통한 STT 실시
  • 기기별로, 또는 확장자에 따라 Whisper가 인식하지 못하는 경우가 발생

    에러 유형

    1. 모델이 인식할 수 없는 file format
    Error: Invalid file format. Supported formats: ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']
    1. 파일 확장자와 file format 불일치
      voice_record.m4aaudio/mpeg ( getContentType()으로 확인되는 파일 형식 )

원인 분석

녹음 환경별 차이

기기파일 확장자mimeType비고
IOS.m4aaudio/x-m4aSupported format 목록에 해당하지 않음
Android.m4aaudio/mpeg명시된 확장자와 실제 mimeType 불일치
  • 다양한 환경에서 녹음된 음성파일을 녹음된 기기의 운영체제로 구분했을 때, 위와 같은 패턴이 관찰됨
  • IOS의 경우 모든 음성 파일이 에러를 발생시키지는 않았지만,audio/x-m4a 포맷은 에러를 발생시킴
  • Android의 경우 명시된 확장자와 실제 mimeType이 일치하지 않았으며, Whisper에 입력시 에러를 발생시킴

예상 원인 및 목표

  • 파일 포맷이 Supported format이 아닐 경우 에러 발생
  • 파일의 확장자뿐만 아니라 내부 오디오 컨테이너의 정합성 검사시, Android 기기에서 녹음된 음성파일의 불일치 특성을 '사용 불가능한 포맷'으로 인식하여 처리를 거부

→ 파일 형식을 변환하여 Whisper에 입력 가능하도록 할 방법을 고안해야함

Solution

시도 1. Blob 재구성을 통한 MIME 타입 정규화

const fileName = audioBlob.getName();
const extensionMatch = fileName.match(/\.([a-z0-9]+)$/i);
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : "";

const mimeTypeMap = { 
"m4a": "audio/x-m4a",
"mp3": "audio/mpeg", 
"wav": "audio/wav", 
"mp4": "audio/mp4", 
"aac": "audio/aac" };

const newFileName = “input_audio.” + extension; 

const newAudioBlob = Utilities.newBlob(
    audioBlob.getBytes(), 
    mimeTypeMap[extension], 
    newFileName
  );
  • 가장 처음 시도한 방법은 audioBlob을 재구성해, 파일명의 확장자와 동일한 mimeType을 갖도록 수정
  • 파일명의 확장자를 정규식으로 확인 후 mimeTypeMap 을 통해 해당하는 mimeType을 찾아 입력

결과

  • mimeType 을 일치시켰으나, 여전히 Whisper는 이를 사용 불가능한 포맷으로 인지함
  • 새로운 Blob을 생성하는 것으로는 해결할 수 없으며, 재인코딩이 필요함을 확인함

시도 2. 자체 배포 API를 통한 파일 형식 변환

현재 작업 환경 개요

  • 불특정 다수의 인원이 음성파일을 각각의 Google Drive 폴더에 업로드
  • 파일들의 링크 또는 ID를 평가용 Google Sheet에 취합하여 기록
  • GAS(Google App Script)를 통해 함수를 실행해 링크/ID를 통해 파일에 접근해, 로컬에 파일을 저장하지 않고 검수 작업을 자동화

→ 파일을 저장하지 않고 GAS 함수 실행 중에 음성 파일 형식 변환 후, Whisper에 입력하는 프로세스

전체 검수 프로세스 개요

검수 대상인 음성 링크를 취합 → 음성 링크에서 음성 파일 불러오기 
→ 음성 파일 포맷 변환 (현재 미구현) → Whisper 모델을 통한 녹음 파일 전사
→ 전사된 스크립트를 통해 녹음 정확도 확인

GCF를 통한 파일 포맷 변환

  • 음성 파일 포맷 변환을 위해서는 ffmpeg 라이브러리 필요하나, GAS 환경에서는 외부 라이브러리를 직접 실행할 수 없었음
  • GCF(Google Cloud Functions)는 Python 환경에서 ffmpeg를 자유롭게 호출할 수 있는 서버리스 환경을 제공하므로, ffmpeg를 실행하기 위한 도구로 적합해 ffmpeg활용한 파일 수신-변환-반환 함수를 작성해 배포함
import functions_framework
from flask import request, send_file
import subprocess
import io
import os
import tempfile
import imageio_ffmpeg as ff

@functions_framework.http
def audio_converter(request):
    # 1. 파일 수신 확인
    input_file = request.files.get('file')
    if not input_file:
        return "No file provided", 400
    
    input_data = input_file.read()
    print(f"DEBUG: Received data size: {len(input_data)} bytes")

    # 2. 임시 파일 생성 및 쓰기
    # 안드로이드의 partial file 에러를 방지하기 위해 데이터를 파일로 만듭니다.
    with tempfile.NamedTemporaryFile(delete=False, suffix=".m4a") as temp_in:
        temp_in.write(input_data)
        temp_in_path = temp_in.name

    # 3. 출력 데이터를 담을 경로 설정
    temp_out_path = temp_in_path + ".wav"

    try:
        ffmpeg_path = ff.get_ffmpeg_exe()
        
        # 4. FFmpeg 명령어 실행 (파이프 대신 파일 경로 사용)
        command = [
            ffmpeg_path,
            '-y',               # 기존 파일 덮어쓰기 허용
            '-i', temp_in_path, # 임시 입력 파일 경로
            '-ar', '16000',
            '-ac', '1',
            '-f', 'wav',
            '-acodec', 'pcm_s16le',
            temp_out_path      # 결과를 파일로 바로 저장
        ]

        process = subprocess.run(
            command, 
            capture_output=True, 
            text=True
        )

        if process.returncode != 0:
            print(f"FFmpeg Error: {process.stderr}")
            return f"Conversion failed: {process.stderr}", 500

        # 5. 변환된 파일 읽어서 반환
        with open(temp_out_path, 'rb') as f:
            wav_data = f.read()
            
        print(f"DEBUG: Final WAV size: {len(wav_data)} bytes")
        return send_file(io.BytesIO(wav_data), mimetype='audio/wav')

    except Exception as e:
        print(f"Exception: {str(e)}")
        return f"Internal Error: {str(e)}", 500

    finally:
        # 6. 사용한 임시 파일 즉시 삭제 (메모리 확보)
        if os.path.exists(temp_in_path):
            os.remove(temp_in_path)
        if os.path.exists(temp_out_path):
            os.remove(temp_out_path)
  • 배포한 url을 GAS에서 호출해 음성 파일을 변환
//GCF 변환 서버를 통해 안드로이드 오디오를 정규화된 WAV로 변환
function getNormalizedWavBlob(originalBlob) {
  const gcfUrl = "(url)"; // GCF 콘솔에서 복사한 URL 입력

  // 파일명과 타입을 명시적으로 재지정하여 Blob 생성
  const fileToUpload = Utilities.newBlob(
    originalBlob.getBytes(),
    originalBlob.getContentType(),
    "input_audio.m4a" // 파일명을 명시해야 GCF가 파일로 인식함
  );

  const options = {
    method: "post",
    payload: {
      file: fileToUpload // GCF의 request.files['file']과 매칭
    },
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(gcfUrl, options);

  if (response.getResponseCode() !== 200) {
    console.log(response.getBlob().getName());
    console.log(response.getBlob().getContentType());
    throw new Error("변환 서버 오류: " + response.getContentText());
  }

  return response.getBlob();
}

결과

  • 파일을 변환해야하는 이벤트가 발생할 때마다, 해당 GAS 함수를 호출해 음성 파일 형식을 변환해 Whisper 모델을 활용한 안정적인 녹음본 검수가 가능해짐

한계점

  • GCF에서 초기 제공하는 기본 크레딧이 충분해 작업해야하는 음성파일의 개수가 해당 크레딧 분량을 초과하지는 않음
    하지만 음성 파일 개수가 늘어나거나, 향후에 사용할 수 있는 범용 툴로 사용하기 위해서는 GCF의 서버를 빌리는 것에서 발생하는 비용을 없앨 방안이 필요함
  • 현재는 음성 파일의 길이가 1분을 초과하지 않아 변환 과정에 긴 시간이 소요되지 않으나, 용량이 큰 파일을 대량으로 다룰 경우 속도 저하 또는 서버 오류가 발생할 수 있음

→ 브라우저 자원을 활용해 파일 형식을 변환할 수 있는 방법을 고안

시도 3. 브라우저 기반 파일 포맷 변환

브라우저 기반 방식 고안

  • 음성 파일 검수 과정에서 무음구간 계산 기능을 구현할 때,GAS에서 지정된 HTML 파일을 실행해 음성 파일을 base64로 인코딩해 전달해, 브라우저 기능을 활용해 무음 구간을 계산하는 기능을 사용
  • 해당 방법에서 아이디어를 얻어, 클라이언트(브라우저) 내 자원을 활용한 파일 포맷 변환 방식을 고안함

전체 프로세스 개요

GAS에서 파일 변환 실행할 HTML 파일 호출 → HTML에 정의된 창(이하 Modal) 렌더링 
→ Modal내부의 'javaScript' 함수 'initProcess' 실행
→ Modal이 GAS의 'getWorkIds' 함수를 호출해 작업할 파일 Id 배열 요청
→ 작업 ID 목록을 순회하며 GAS의 'getBase64'함수에 Id 입력해, 음성 파일의 base64 인코딩 값 요청
→ Web Audio API를 통해 base64 값을 audioBuffer로 변환 후 wav 형식으로 파일 변환
→ 변환된 파일을 GAS의 Whisper 실행 함수에 전달해 STT 실행
  • GAS에서 실행해야 하는 기능과 브라우저 상에서 실행해야 하는 기능을 구분하고, 서로 간에 필요한 경우 어떻게 정보를 주고 받을지 고려
  • GAS브라우저 간에는 base64 형식은 가능하지만, Blob과 같은 복잡한 구조는 전달할 수 없는 점을 고려해야함

GAS 실행 함수

function getAudioBase64(fileId) {
  const f = DriveApp.getFileById(fileId);

  const blob = f.getBlob();
  const bytes = blob.getBytes();
  const b64 = Utilities.base64Encode(bytes);

  return {
    fileId,
    name: f.getName(),
    mimeType: blob.getContentType(),
    base64: b64
  };
}
  • DriveApp 사용 : 파일 id를 통해 음성 파일을 불러올 때는 DriveApp 사용이 필요하나, 해당 기능은 구글 서버 사이드에서만 작동하므로 GAS 상에서 음성 파일을 찾아 필요한 정보를 객체로 묶어 Modal로 전달
  • Base64 인코딩 : 음성파일을 직접 전달할 수 없기 때문에 base64 형태로 인코딩 해 음성파일을 전달
function writeResultToSheet(base64Data, index) {
  try {
    // 설정 정보 (실제 시트 및 컬럼 위치로 수정 필요)    
    
    (기록할 시트 정보)

    // Base64 문자열을 디코딩하여 Blob 생성
    const decodedData = Utilities.base64Decode(base64Data);
    const audioBlob = Utilities.newBlob(decodedData, "audio/wav", "audio_" + (index + 1) + ".wav");
    
    //Whisper를 통한 STT
    const result = transcribeWithWhisper(audioBlob)

    (시트에 결과 기록)
    
  } catch (e) {
    Logger.log("저장 실패: " + e.message);
    return { success: false, error: e.message };
  }
}
  • Base64 디코딩 : 음성파일, Blob으로 입력받을 수 없으므로 Base64 형식을 입력받아, 새로운 Blob 생성
    async function base64ToAudioBuffer(base64) {
      const binary = atob(base64);
      const len = binary.length;
      const bytes = new Uint8Array(len);
      for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);

      const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      return await audioCtx.decodeAudioData(bytes.buffer);
    }
  • 입력받은 Base64 형식 값을 AudioBuffer로 변환
  • 브라우저의 오디오 연산 환경인 AudioContext 객체를 생성
  • AudioContext 객체가 입력된 데이터 포맷을 자동으로 해석해 AudioBuffer 객체를 반환
    function bufferToWav(buffer) {
      // buffer가 정상인지 체크
      if (!buffer) {
        addLog("오류: buffer가 비어있습니다.");
      }
      if (typeof buffer.getChannelData !== 'function') {
        addLog("오류: 전달된 객체가 AudioBuffer 형식이 아닙니다.");
      }

      const sampleRate = buffer.sampleRate;
      const length = buffer.length * 2;
      const view = new DataView(new ArrayBuffer(44 + length));
      const writeStr = (off, s) => {
        for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
      };

      // 1. WAV 헤더 작성 단계
      addLog("WAV 헤더 작성 중...");
      writeStr(0, 'RIFF');
      view.setUint32(4, 36 + length, true);
      writeStr(8, 'WAVE');
      writeStr(12, 'fmt ');
      view.setUint32(16, 16, true);
      view.setUint16(20, 1, true); // PCM
      view.setUint16(22, 1, true); // Mono
      view.setUint32(24, sampleRate, true); //sampleRate 설정
      view.setUint32(28, sampleRate * 2, true);
      view.setUint16(32, 2, true);
      view.setUint16(34, 16, true);
      writeStr(36, 'data');
      view.setUint32(40, length, true);

      // 2. 오디오 데이터 작성 단계
      const channelData = buffer.getChannelData(0);
      for (let i = 0, off = 44; i < channelData.length; i++, off += 2) {
        const s = Math.max(-1, Math.min(1, channelData[i]));
        view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }

      const uint8Array = new Uint8Array(view.buffer);
  
      // 바이트 배열을 이진 문자열로 변환 후 btoa 실행
      let binary = '';
      const len = uint8Array.byteLength;
      for (let i = 0; i < len; i++) {
          binary += String.fromCharCode(uint8Array[i]);
          }
  
      return window.btoa(binary); // 순수 Base64 문자열 반환
    }
  • 리샘플링된 Audiobuffer를 라이브러리 없이 바이트 단위로 파일 헤더를 작성해 wav 포맷으로 패키징
  • 44바이트의 헤더 작성 이후, wav 포맷에 맞춰 소수점 형태의 데이터를 정수형으로 바꾸면서도 클리핑 등 문제가 생기지 않도록 데이터 정제
  • 새로 구성한 AudioBuffer를 이진 문자열로 변환해 Base64 형식으로 바꿔 GAS에 안정적으로 전달할 수 있게함

결론

  • GCF를 거치지 않고 브라우저 내 기능을 활용해 음성 파일을 변환하고, 이렇게 변환한 음성 파일을 Whisper에 입력해 STT를 실행하는 프로세스를 구축할 수 있었음

Result

업무 관련

  • 여러 환경에서 녹음된 다양한 확장자를 가진 음성파일을 매끄럽게 처리해 STT 검수를 실시하는 프로세스 구축

학습 관련

  • GCF를 활용한 자체 api 배포를 통해 ffmpeg 활용 음성 파일 변환 기능 구현
  • 브라우저의 Web Audio API와 자체 헤더 및 채널 샘플링 과정을 통해 클라이언트 상에서의 음성 파일 변환 기능 구현
  • 음성 파일 데이터의 byte 단위의 low-level 구조 학습
  • GAS-브라우저 간 통신을 위한 Base64 개념 학습 및 활용

0개의 댓글