- 문제: Android/iOS 기기별 음성 녹음 포맷 불일치로 인한 Whisper AI 인식 오류 발생
- 해결: GCF(FFmpeg) 서버 방식을 거쳐, 최종적으로 브라우저(Web Audio API) 기반 재인코딩 파이프라인 구축
- 성과: Web Audio API 기반의 브라우저 재인코딩 공정으로 전환하여, 서버 비용 없이 녹음본을 자동 검수할 수 있는 데이터 정규화 파이프라인을 구축
OpenAI의 Whisper를 통한 STT 실시Whisper가 인식하지 못하는 경우가 발생에러 유형
- 모델이 인식할 수 없는 file format
Error: Invalid file format. Supported formats: ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']
- 파일 확장자와 file format 불일치
voice_record.m4a≠audio/mpeg(getContentType()으로 확인되는 파일 형식 )
| 기기 | 파일 확장자 | mimeType | 비고 |
|---|---|---|---|
| IOS | .m4a | audio/x-m4a | Supported format 목록에 해당하지 않음 |
| Android | .m4a | audio/mpeg | 명시된 확장자와 실제 mimeType 불일치 |
IOS의 경우 모든 음성 파일이 에러를 발생시키지는 않았지만,audio/x-m4a 포맷은 에러를 발생시킴Android의 경우 명시된 확장자와 실제 mimeType이 일치하지 않았으며, Whisper에 입력시 에러를 발생시킴Supported format이 아닐 경우 에러 발생→ 파일 형식을 변환하여 Whisper에 입력 가능하도록 할 방법을 고안해야함
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을 생성하는 것으로는 해결할 수 없으며, 재인코딩이 필요함을 확인함Google Drive 폴더에 업로드Google Sheet에 취합하여 기록GAS(Google App Script)를 통해 함수를 실행해 링크/ID를 통해 파일에 접근해, 로컬에 파일을 저장하지 않고 검수 작업을 자동화→ 파일을 저장하지 않고 GAS 함수 실행 중에 음성 파일 형식 변환 후, Whisper에 입력하는 프로세스
검수 대상인 음성 링크를 취합 → 음성 링크에서 음성 파일 불러오기
→ 음성 파일 포맷 변환 (현재 미구현) → Whisper 모델을 통한 녹음 파일 전사
→ 전사된 스크립트를 통해 녹음 정확도 확인
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)
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();
}
Whisper 모델을 활용한 안정적인 녹음본 검수가 가능해짐GCF에서 초기 제공하는 기본 크레딧이 충분해 작업해야하는 음성파일의 개수가 해당 크레딧 분량을 초과하지는 않음GCF의 서버를 빌리는 것에서 발생하는 비용을 없앨 방안이 필요함→ 브라우저 자원을 활용해 파일 형식을 변환할 수 있는 방법을 고안
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 생성Modal 실행 함수 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 포맷으로 패키징wav 포맷에 맞춰 소수점 형태의 데이터를 정수형으로 바꾸면서도 클리핑 등 문제가 생기지 않도록 데이터 정제AudioBuffer를 이진 문자열로 변환해 Base64 형식으로 바꿔 GAS에 안정적으로 전달할 수 있게함GCF를 거치지 않고 브라우저 내 기능을 활용해 음성 파일을 변환하고, 이렇게 변환한 음성 파일을 Whisper에 입력해 STT를 실행하는 프로세스를 구축할 수 있었음GCF를 활용한 자체 api 배포를 통해 ffmpeg 활용 음성 파일 변환 기능 구현Web Audio API와 자체 헤더 및 채널 샘플링 과정을 통해 클라이언트 상에서의 음성 파일 변환 기능 구현GAS-브라우저 간 통신을 위한 Base64 개념 학습 및 활용