인게임 사운드 무음·볼륨 왜곡 구간 자동 주파수 스캔과 QA 리포트 생성
게임 빌드의 WAV·OGG 사운드를 자동 분석해 무음, 클리핑, 과도한 볼륨 변화, 주파수 이상 구간을 찾고 재현 가능한 QA 리포트로 만드는 방법을 정리합니다.

핵심 요약
게임 빌드에서 추출한 WAV·OGG 파일을 일정한 프레임으로 나눈 뒤 RMS·dBFS·peak·spectral centroid·zero-crossing rate를 계산하면 무음, 풀 스케일 근접 구간, 급격한 볼륨 변화, 주파수 대역 이상 후보를 자동으로 찾을 수 있습니다. 진폭 임계값만으로 실제 클리핑을 확정할 수는 없습니다. 검출 결과는 파일명, 시작·종료 시간, 측정값, 심각도, 판정 규칙을 포함한 JSON·HTML QA 리포트로 저장하면 CI 빌드와 수동 검수를 함께 연결할 수 있습니다.
이 글의 기준 구현은 Python 3.12, NumPy 2.x, SciPy 1.13 이상, SoundFile 0.12 이상을 사용하며 샘플링 레이트가 다른 파일도 동일한 분석 파이프라인으로 처리합니다.
왜 인게임 사운드에 자동 주파수 스캔이 필요한가?
오디오 버그는 단순히 파일이 재생되는지 확인하는 것만으로 발견하기 어렵습니다. 다음 문제는 게임 플레이 중 짧은 구간에서만 발생하거나 특정 믹서 상태에서만 드러납니다.
| 문제 유형 | 대표 증상 | 자동 분석 지표 |
|---|---|---|
| 무음 구간 | 효과음이 재생됐지만 들리지 않음 | RMS, dBFS |
| 풀 스케일 근접 | 클리핑 가능성이 있는 큰 진폭 | peak, 임계값 이상 샘플 수 |
| 볼륨 왜곡 | 같은 이벤트인데 음량이 갑자기 커지거나 작아짐 | 프레임별 dBFS 변화량 |
| 주파수 이상 | 저역 소실, 고역 노이즈, 필터 오류 | FFT, spectral centroid, band energy |
| 인코딩 이상 | 끝부분 절단, 비정상적인 채널 또는 샘플레이트 | 파일 메타데이터, 디코더 오류 |
무음 검출은 전체 파일의 평균 음량만 보면 놓치기 쉽습니다. 따라서 파일을 10~50ms 단위 프레임으로 나누고 인접 프레임의 변화를 함께 측정해야 합니다.

무음과 볼륨 왜곡은 어떤 기준으로 판정할까?
분석 기준은 프로젝트의 믹싱 정책에 맞춰 설정해야 합니다. 보이스, UI 클릭음, 폭발음은 정상 음량과 주파수 분포가 다르므로 모든 오디오에 같은 임계값을 적용하면 오탐이 늘어납니다.
RMS와 dBFS 계산
정규화된 PCM 샘플을 x_i라고 할 때 프레임 RMS는 다음과 같이 계산합니다.
dBFS는 디지털 최대 진폭 대비 현재 진폭을 데시벨로 표현한 값입니다. 0 dBFS는 디지털 최대치이며 일반적인 음원에서는 충분한 헤드룸을 확보해야 합니다.
실무에서는 다음과 같은 초기값을 사용할 수 있습니다.
| 파라미터 | 예시 초기값 | 의미 |
|---|---|---|
| frame_ms | 20 | 분석 프레임 길이 |
| hop_ms | 10 | 프레임 이동 간격 |
| silence_dbfs | -60 dBFS | 무음으로 볼 음량 기준 |
| min_silence_ms | 80ms | 이보다 짧으면 무음 이벤트에서 제외 |
| near_full_scale_threshold | 0.999 | 검수 대상으로 표시할 정규화 샘플 크기 |
| max_delta_db | 12 dB | 인접 프레임 간 허용 음량 변화 |
| fft_size | 2048 | FFT 윈도 크기 |
무음 임계값은 원본 파일의 노이즈 플로어보다 높게 잡아야 합니다. 배경음이나 루프 사운드에서는 파일 앞뒤의 의도적인 페이드 구간을 별도 정책으로 제외하는 것이 좋습니다.
spectral centroid와 대역별 에너지
spectral centroid는 주파수 스펙트럼의 무게중심을 나타냅니다.
이 값 하나만으로 오류를 확정할 수는 없지만 정상 샘플의 기준 범위를 벗어났는지 확인하는 데 유용합니다. 예를 들어 UI 사운드에서 갑자기 centroid가 200Hz 아래로 떨어지면 저역 필터가 잘못 적용됐을 가능성이 있습니다. 반대로 고역 에너지가 특정 구간에서 급격히 증가하면 디코딩 노이즈나 클리핑을 의심할 수 있습니다.
대역별 에너지는 다음처럼 프로젝트에 맞는 주파수 구간으로 나눠 저장합니다.
- Low: 20~250Hz
- Mid: 250~4,000Hz
- High: 4,000~20,000Hz
샘플링 레이트가 44,100Hz인 경우 나이퀴스트 주파수는 22,050Hz입니다. 따라서 분석 가능한 최고 주파수는 파일 샘플링 레이트의 절반으로 제한해야 합니다.
Python으로 오디오 주파수 스캔기를 어떻게 구현할까?
먼저 입력 폴더의 WAV·OGG 파일을 읽고 모노로 변환한 뒤 프레임별 지표를 계산합니다. SoundFile이 OGG 디코딩에 실패하는 환경에서는 FFmpeg로 WAV를 임시 변환하는 전처리 단계를 추가할 수 있습니다.
from __future__ import annotations
import argparse
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
import soundfile as sf
from scipy.signal import get_window
@dataclass
class Finding:
file: str
kind: str
start_ms: float
end_ms: float
severity: str
value: float
threshold: float
message: str
FRAME_MS = 20
HOP_MS = 10
SILENCE_DBFS = -60.0
MIN_SILENCE_MS = 80.0
NEAR_FULL_SCALE_THRESHOLD = 0.999
MAX_DELTA_DB = 12.0
FFT_SIZE = 2048
EPSILON = 1e-12
def to_mono(samples: np.ndarray) -> np.ndarray:
if samples.ndim == 1:
return samples.astype(np.float32)
return samples.mean(axis=1).astype(np.float32)
def frame_signal(signal: np.ndarray, frame_size: int, hop_size: int):
if len(signal) < frame_size:
signal = np.pad(signal, (0, frame_size - len(signal)))
for start in range(0, len(signal) - frame_size + 1, hop_size):
yield start, signal[start:start + frame_size]
def analyze_file(path: Path) -> list[Finding]:
samples, sample_rate = sf.read(path, always_2d=False, dtype="float32")
signal = to_mono(samples)
frame_size = max(1, round(sample_rate * FRAME_MS / 1000))
hop_size = max(1, round(sample_rate * HOP_MS / 1000))
window = get_window("hann", frame_size, fftbins=True).astype(np.float32)
metrics = []
for start, frame in frame_signal(signal, frame_size, hop_size):
rms = float(np.sqrt(np.mean(np.square(frame))))
dbfs = 20.0 * math.log10(max(rms, EPSILON))
peak = float(np.max(np.abs(frame)))
near_full_scale = int(np.sum(np.abs(frame) >= NEAR_FULL_SCALE_THRESHOLD))
fft_size = 1 << (max(FFT_SIZE, frame_size) - 1).bit_length()
spectrum = np.abs(np.fft.rfft(frame * window, n=fft_size))
frequencies = np.fft.rfftfreq(fft_size, 1.0 / sample_rate)
centroid = float(
np.sum(frequencies * spectrum) / max(np.sum(spectrum), EPSILON)
)
metrics.append({
"start_ms": start * 1000.0 / sample_rate,
"end_ms": min((start + frame_size) * 1000.0 / sample_rate,
len(signal) * 1000.0 / sample_rate),
"dbfs": dbfs,
"peak": peak,
"near_full_scale": near_full_scale,
"centroid": centroid,
})
findings: list[Finding] = []
silence_start = None
for index, metric in enumerate(metrics):
if metric["dbfs"] <= SILENCE_DBFS:
if silence_start is None:
silence_start = index
elif silence_start is not None:
start = metrics[silence_start]["start_ms"]
end = metrics[index - 1]["end_ms"]
if end - start >= MIN_SILENCE_MS:
findings.append(Finding(
file=str(path),
kind="silence",
start_ms=round(start, 2),
end_ms=round(end, 2),
severity="warning",
value=round(min(m["dbfs"] for m in metrics[silence_start:index]), 2),
threshold=SILENCE_DBFS,
message="무음 임계값 이하의 연속 구간입니다.",
))
silence_start = None
if metric["near_full_scale"] > 0:
findings.append(Finding(
file=str(path),
kind="near_full_scale",
start_ms=round(metric["start_ms"], 2),
end_ms=round(metric["end_ms"], 2),
severity="warning",
value=float(metric["near_full_scale"]),
threshold=0.0,
message="풀 스케일 근접 임계값 이상인 샘플이 있습니다. 청취 또는 추가 분석이 필요합니다.",
))
if index > 0:
delta = abs(metric["dbfs"] - metrics[index - 1]["dbfs"])
if delta >= MAX_DELTA_DB:
findings.append(Finding(
file=str(path),
kind="volume_jump",
start_ms=round(metric["start_ms"], 2),
end_ms=round(metric["end_ms"], 2),
severity="warning",
value=round(delta, 2),
threshold=MAX_DELTA_DB,
message="인접 프레임 사이의 음량 변화가 큽니다.",
))
if silence_start is not None:
start = metrics[silence_start]["start_ms"]
end = metrics[-1]["end_ms"]
if end - start >= MIN_SILENCE_MS:
findings.append(Finding(
file=str(path),
kind="silence",
start_ms=round(start, 2),
end_ms=round(end, 2),
severity="warning",
value=round(min(m["dbfs"] for m in metrics[silence_start:]), 2),
threshold=SILENCE_DBFS,
message="파일 끝부분에 무음 구간이 있습니다.",
))
return findings
def main(input_dir: str, output_path: str):
root = Path(input_dir)
findings = []
for path in sorted(root.rglob("*")):
if path.suffix.lower() in {".wav", ".ogg"}:
try:
findings.extend(analyze_file(path))
except Exception as error:
findings.append(Finding(
file=str(path),
kind="decode_error",
start_ms=0.0,
end_ms=0.0,
severity="error",
value=0.0,
threshold=0.0,
message=f"오디오를 분석할 수 없습니다: {error}",
))
report = {
"tool": "audio-frequency-scan",
"python": "3.12",
"thresholds": {
"silence_dbfs": SILENCE_DBFS,
"min_silence_ms": MIN_SILENCE_MS,
"max_delta_db": MAX_DELTA_DB,
"near_full_scale_threshold": NEAR_FULL_SCALE_THRESHOLD,
},
"finding_count": len(findings),
"findings": [asdict(item) for item in findings],
}
report_file = Path(output_path)
report_file.parent.mkdir(parents=True, exist_ok=True)
report_file.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="Build/Audio")
parser.add_argument("--output", default="artifacts/audio-qa-report.json")
args = parser.parse_args()
main(args.input, args.output)
실제 운영에서는 무음 이벤트가 같은 프레임에서 반복 등록되지 않도록 인접 finding을 병합하는 후처리가 필요합니다. 또한 배경음악처럼 의도적인 무음과 페이드가 있는 자산은 메타데이터의 allow_silence 플래그로 예외 처리하는 편이 안전합니다.
자동 주파수 스캔 결과를 QA 리포트로 어떻게 만들까?
JSON은 CI와 다른 도구가 읽기 좋고 HTML은 QA 담당자가 빠르게 확인하기 좋습니다. 하나의 원본 JSON에서 두 형식을 생성하면 판정 기준과 표시 결과가 어긋나는 문제를 줄일 수 있습니다.
권장 리포트 필드는 다음과 같습니다.
| 필드 | 예시 | 목적 |
|---|---|---|
| file | sfx/weapon_fire_03.ogg | 문제가 발생한 자산 식별 |
| kind | silence, near_full_scale, volume_jump | 문제 유형 분류 |
| start_ms | 820.0 | 재생 위치로 바로 이동 |
| end_ms | 930.0 | 구간 범위 표시 |
| severity | warning, error | 빌드 실패 여부 결정 |
| value | -72.4 | 실제 측정값 |
| threshold | -60.0 | 적용한 판정 기준 |
| message | 무음 임계값 이하... | QA 조치 안내 |
리포트에는 분석 도구 버전, 커밋 SHA, 빌드 번호, 플랫폼, 샘플링 레이트도 함께 기록하는 것이 좋습니다. 같은 오디오 파일이라도 플랫폼별 인코더나 런타임 믹서 설정이 다르면 결과가 달라질 수 있기 때문입니다.

HTML 리포트의 기본 화면 구성
HTML 리포트는 다음 순서로 구성하면 검수 속도가 빠릅니다.
- 전체 파일 수와 finding 수를 요약합니다.
error와warning을 심각도별로 분리합니다.- 파일명, 문제 유형, 시작 시간, 종료 시간, 실제값, 임계값을 표로 보여줍니다.
- WAV 원본이 있으면 해당 구간의 파형과 스펙트럼 이미지를 연결합니다.
- 필터로 플랫폼, 씬, 사운드 버스, 문제 유형을 좁힐 수 있게 합니다.
시작 시간을 샘플 번호가 아니라 밀리초로 저장하면 QA 담당자가 DAW나 게임 리플레이에서 즉시 해당 구간을 찾을 수 있습니다.
Unity 빌드와 CI에서 오디오 QA를 어떻게 연결할까?
Unity 프로젝트에서는 Addressables 또는 빌드 산출물에 포함된 오디오 자산을 대상으로 스캔할 수 있습니다. 중요한 점은 원본 Assets/ 파일만 검사하지 않고 실제 빌드에 사용되는 압축·변환 결과도 별도로 검사하는 것입니다.
권장 파이프라인은 다음과 같습니다.
flowchart LR
A[Unity 빌드] --> B[오디오 자산 추출]
B --> C[PCM 디코딩]
C --> D[프레임별 RMS FFT 분석]
D --> E[무음 클리핑 볼륨 변화 판정]
E --> F[JSON QA 리포트]
F --> G{error 존재}
G -->|예| H[CI 실패 및 아티팩트 보관]
G -->|아니오| I[빌드 통과]
CI 단계에서는 JSON의 finding_count만 보는 것보다 심각도에 따라 종료 코드를 나누는 편이 좋습니다.
python tools/audio_scan.py --input Build/Audio --output artifacts/audio-qa-report.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$report = Get-Content artifacts/audio-qa-report.json | ConvertFrom-Json
$errors = @($report.findings | Where-Object { $_.severity -eq "error" })
if ($errors.Count -gt 0) {
Write-Error "Audio QA failed: $($errors.Count) error(s)"
exit 1
}
운영 정책 예시는 다음과 같습니다.
decode_error: 즉시 빌드 실패near_full_scale: 경고로 기록하고 청취 또는 true-peak/클리핑 검사를 추가한다.volume_jump: 경고로 기록하고 동일 자산에서 반복되면 실패silence: 짧은 UI 효과음은 경고, 보이스와 필수 게임 이벤트는 오류centroid이상: 자동 실패보다 수동 확인 큐로 전송
이 정책은 사운드 버스와 자산 유형별로 다르게 설정해야 합니다. 폭발음의 높은 peak를 오류로 처리하면 정상 자산을 계속 차단할 수 있습니다.
자동 검출의 오탐을 어떻게 줄일까?
첫째, 분석 대상을 유형별로 나눕니다. voice, ui, sfx, music, ambience마다 무음 허용 시간과 음량 변화 기준을 별도 설정합니다.
둘째, 파일 내부 기준과 프로젝트 기준을 분리합니다. 파일 시작과 끝의 페이드는 파일 품질 문제일 수도 있지만 음악 루프의 의도된 구성일 수도 있으므로 ignore_head_ms, ignore_tail_ms 같은 예외 파라미터를 둡니다.
셋째, 단일 지표로 실패시키지 않습니다. 예를 들어 spectral centroid가 낮다는 사실만으로 저역 소실을 확정하지 말고 Low·Mid·High 대역 에너지 비율과 함께 판정합니다.
넷째, 정상 샘플을 기준으로 비교합니다. 동일한 이벤트의 이전 버전 또는 승인된 레퍼런스 파일이 있다면 절대 임계값보다 상대 변화량을 함께 기록하는 것이 유용합니다.
다섯째, 재현 정보를 남깁니다. 분석 결과만 저장하지 말고 파일 해시, 도구 버전, 임계값 설정 파일, 빌드 번호를 함께 저장해야 패치 후 결과가 달라진 이유를 추적할 수 있습니다.
Step-by-Step: 인게임 오디오 QA 자동화를 도입하는 순서
1단계. 검사 범위와 실패 정책을 정의한다
먼저 실제 플레이에 사용되는 오디오 포맷과 플랫폼을 정합니다. 이후 문제 유형별로 error, warning, ignore를 정하고 자산 유형별 예외 정책을 작성합니다.
2단계. 오디오를 프레임 단위로 분석한다
20ms 프레임과 10ms hop을 기준으로 RMS·dBFS·peak·풀 스케일 근접 샘플 수·spectral centroid·대역 에너지를 계산합니다. 짧은 효과음은 파일 길이가 프레임보다 짧을 수 있으므로 zero padding 정책도 명시해야 합니다.
3단계. finding을 병합하고 리포트를 만든다
연속된 무음 프레임을 하나의 구간으로 병합하고 인접한 볼륨 변화 이벤트도 중복 제거합니다. 그 결과를 JSON으로 저장한 뒤 HTML 표와 파형·스펙트럼 미리보기로 변환합니다.
4단계. CI와 패치 검증에 연결한다
빌드 후 실제 산출물을 스캔하고 error가 있으면 CI를 실패시킵니다. 패치 빌드에서는 전체 스캔과 변경된 오디오 자산만 스캔하는 빠른 모드를 함께 제공하면 검증 시간을 줄일 수 있습니다.
5단계. 임계값을 데이터로 보정한다
초기에는 오탐이 발생하더라도 리포트에 모든 측정값을 남깁니다. QA가 정상·비정상 판정을 누적하면 자산 유형별 임계값을 조정하고 예외 목록 대신 규칙으로 흡수합니다.
자주 묻는 질문(FAQ)
무음 검출에 -60dBFS를 사용하면 충분한가요?
항상 충분하지는 않습니다. 배경 노이즈와 자산 유형에 따라 달라지므로 실제 노이즈 플로어를 측정한 뒤 silence_dbfs와 min_silence_ms를 함께 보정해야 합니다.
peak가 1.0에 가까우면 무조건 클리핑인가요?
아닙니다. 한두 개 샘플의 peak나 near_full_scale_threshold 초과만으로 클리핑을 확정하지 말고 연속된 평탄 구간과 true-peak 측정, 청취 결과를 함께 확인합니다.
FFT만으로 인게임 음량 왜곡을 찾을 수 있나요?
FFT는 주파수 분포를 보여주지만 음량 자체를 직접 판정하지는 않습니다. RMS·dBFS로 시간에 따른 음량 변화를 계산하고 FFT와 spectral centroid는 주파수 대역 이상을 확인하는 보조 지표로 사용해야 합니다.
정리
인게임 오디오 QA의 핵심은 파일 전체 평균이 아니라 짧은 시간 프레임의 변화를 기록하는 것입니다. RMS·dBFS로 무음과 볼륨 점프를 찾고 peak로 클리핑을 검출하며 FFT·spectral centroid·대역 에너지로 주파수 이상을 분류하면 수동 청취만으로 놓치기 쉬운 문제를 빌드 단계에서 발견할 수 있습니다.
최종적으로는 실제 빌드 산출물을 분석하고 JSON·HTML 리포트에 시간 위치와 판정 기준을 남겨야 합니다. 이렇게 구성하면 오디오 버그를 재현 가능한 QA 이슈로 전환하고 패치 이후에도 동일한 기준으로 회귀 검증할 수 있습니다.



