안녕하세요! 피피아노입니다 🎵
육아 기록 앱을 개발하며, 아기의 울음소리를 분석해 감정을 분류하는 기능을 구현하게 되었습니다.
부모 입장에서 '왜 우는지'를 빠르게 파악하는 것은 매우 중요한 일이었고,
이 문제를 앱이 기술적으로 도울 수 있다면 큰 의미가 있겠다는 생각이 들었습니다.
이번 글에서는 해당 기능을 구현하며 어떤 기술을 사용했고,
구현 중 어떤 문제를 마주했고, 어떻게 해결했는지를 자세히 정리해보려고 합니다.
그럼 바로 시작하겠습니다!
기능 개요
- 기능: 아기의 울음소리를 분석하여 감정(예: 배고픔, 졸림 등)을 자동으로 분류
- 사용 기술: Swift, SwiftUI, AVFoundation, Accelerate, Core ML
- 모델: DeepInfant V2 CoreML 모델 사용
- 입력: 마이크로 실시간 녹음된 음성 (약 7초)
- 출력: 감정 결과 (EmotionType) + 신뢰도 (Confidence Score)
기술 선택 배경
- AVAudioEngine: 실시간 오디오 캡처 및 커스터마이징이 유연하며, AVAudioInputNode로 오디오 버퍼를 다룰 수 있음
- Accelerate Framework: MFCC를 직접 계산하기 위한 고성능 신호처리 라이브러리 (DSP 연산 포함)
- Core ML: 온디바이스에서 머신러닝 추론을 빠르고 안전하게 실행할 수 있어 iOS 환경에 최적
- SwiftUI: 개발 효율 증가 및 멀티 플랫폼 대응, 상태 기반 화면 전환에 이점
- MVVM: 역할 분리의 명확성으로 기능 확장 및 유지보수가 쉽고 협업에 용이
음성 녹음 및 전처리
먼저 AVAudioEngine을 사용해서 실시간으로 오디오 데이터를 수집하였습니다.
기본 마이크 입력은 보통 44100Hz이지만, 분석에 필요한 감정 정보는 대부분 0~8000Hz에 분포해 있기 때문에, 이를 22050Hz로 다운샘플링하여 분석에 필요한 주파수 해상도에 맞췄습니다.
(0~8000Hz 사이에서 22050Hz로 선택한 이유는 DeepInfant 모델이 22050Hz Waveform 데이터를 입력으로 훈련되었기 때문에 이에 맞추어서 다운샘플링을 하였습니다.)
converter = AVAudioConverter(from: inputFormat, to: outputFormat)
MFCC 추출 로직과 Accelerate 프레임워크 선택 이유
일반적으로 음성 분석에서는 Waveform 그대로 모델에 넣지 않고, MFCC(Mel-Frequency Cepstral Coefficients)라는 음향 특징 벡터로 변환합니다.
제가 사용한 오픈소스 Core ML(DeepInfant V2) 모델 역시 내부적으로 Waveform을 입력받으면 MFCC로 변환해서 해당 음성을 분석하고 있었기 때문에, 입력 데이터의 일관성을 유지하고 모델의 성능을 최대한 활용하기 위해, 앱 내에서 직접 MFCC 추출 로직을 구현하였습니다.
MFCC 추출 단계
- 윈도잉(Hann Window): 프레임별 경계를 부드럽게 처리
- FFT 변환: 주파수 영역으로 변환
- Mel 필터 적용: 인간 청각의 감지 스케일에 맞춤
- Log 변환: 스펙트럼의 비율 왜곡 방지
- DCT (Discrete Cosine Transform): 에너지 정보를 압축한 최종 MFCC 생성
이 과정을 위해서 Apple의 고성능 수치 연산 프레임워크인 Accelerate를 선택해서 사용했습니다. Acclerate 프레임워크는 DSP(디지털 신호처리) 연산 기능을 제공하고 있어서 선택하였습니다. 그리고 사실 Python의 librosa나 scipy처럼 오디오 분석에 특화된 라이브러리가 Swift에는 많지 않기 때문에, vDSP는 사실상 iOS 환경에서 MFCC를 직접 구현하려면 가장 현실적인 선택지이기도 했기 때문에 선택하였습니다.
아래에 그 외에도 Accelerate의 장점에 대해서 표로 간단히 정리해 두겠습니다.
기능 | Accelerate (vDSP) 장점 |
FFT | 실시간 처리에 적합한 고성능 radix-2 FFT 지원 |
DCT | MFCC에 필요한 Type-II DCT 기본 제공 |
창 함수 (Windowing) | Hann, Hamming 등 DSP 전용 윈도우 함수 제공 |
벡터화 최적화 | SIMD 기반 연산으로 계산 속도가 매우 빠름 |
네이티브 통합 | Swift 코드에서 C 의존 없이 직접 사용 가능 |
https://developer.apple.com/documentation/accelerate/vdsp/
vDSP | Apple Developer Documentation
An enumeration that acts as a namespace for Swift overlays to vDSP.
developer.apple.com
정확도 개선을 위한 전처리
첫 번째 시도 - MFCC 평균 벡터 방식
처음 기능을 구현할 때는, MFCC를 여러 프레임으로 나누어서 추출한 뒤 프레임별 벡터들의 평균을 구해서 하나의 벡터로 요약해 모델에 입력했습니다. 해당 방식을 선택했던 이유는 모델에 넘겨줄 데이터를 고정된 크기로 정리해야 했고 평균을 취하면 간단하게 [Float] 형태로 변환할 수 있었기 때문입니다.
하지만 이 방식을 정보 손실로 인한 정확도 저하라는 한계가 있었습니다.
평균 벡터 방식의 문제
평균 벡터를 입력했을 때 다음과 같은 현상이 나타났습니다.
- 같은 울음소리인데 다른 결과가 나옴
- 비슷한 환경에서 분석 결과가 다름
- 모델 출력 확률값(confidence)이 매우 낮게 나오는 경우가 많음
MFCC는 본질적으로 시간에 따른 주파수 특징의 변화를 담고 있습니다.
즉, 각 프레임마다 담고 있는 주파수 정보는 다르고, 그 변화 자체가 감정 분석의 핵심이라는 걸 알게 되었습니다.
그런데 여기에서 평균을 내버리면?
시간 흐름에 따른 특징의 모든 변화가 사라지고 결국 정적인 벡터 하나로 요약되기 때문에, 감정 간 미묘한 차이를 모델이 학습된 대로 구분하지 못하게 됩니다.
그래서 저는 2가지 방식을 도입해서 성능을 개선시켜 보았습니다.
- 프레임 수를 고정하여 모델이 기대하는 입력 형태([13, 60])에 맞춤
- Min-Max 정규화를 적용하여 coefficient 값의 분포를 일정하게 스케일링
프레임 수 고정 - Padding & Truncating
모델 입력은 (13, 60) 크기의 MLMultiArray를 요구하고 있었습니다.
(13은 각 프레임에서 추출한 MFCC coefficent 개수, 60은 전체 입력에서 사용되는 프레임 수, 즉 시간 축에서의 분해능)
즉, 모델은 "총 60개의 프레임에 대해, 각 프레임마다 13개의 MFCC 특징을 가진 2차원 입력"을 기대하고 있었습니다.
하지만 녹음 시간이나 환경에 따라 추출되는 MFCC 프레임 수는 유동적이기 때문에 고정된 길이를 보장하지 않습니다. 녹음 시간, 프레임 길이, hop size, 환경 소음에 따라 추출되는 프레임 수는 유동적으로 바뀔 수 있습니다.
따라서 모델에 입력하려면 항상 (13, 60) 크기를 만족해야 했고, 이를 고정된 해결하기 위해 아래 로직을 구현했습니다.
private func padOrTruncate(mfccs: [[Float]], targetFrameCount: Int = 60) -> [[Float]] {
var padded = mfccs
if mfccs.count < targetFrameCount {
let zeroFrame = [Float](repeating: 0.0, count: mfccs[0].count)
let paddingCount = targetFrameCount - mfccs.count
padded += Array(repeating: zeroFrame, count: paddingCount)
} else if mfccs.count > targetFrameCount {
padded = Array(mfccs.prefix(targetFrameCount))
}
return padded
}
- 프레임이 60보다 짧으면 0으로 패딩
- 프레임이 60보다 길면 앞에서부터 자르기(prefix)
여기에서 왜 0으로 패딩을 하는지 간단하게 설명을 해보겠습니다.
전체 MFC 구조는 이런 느낌입니다.
[
[mfcc1_1, mfcc1_2, ..., mfcc1_13], // 프레임 1
[mfcc2_1, mfcc2_2, ..., mfcc2_13], // 프레임 2
...
[mfccN_1, mfccN_2, ..., mfccN_13] // 프레임 N
]
- 한 프레임 = 오디오 신호의 짧은 구간 (예: 23ms)
- 이 프레임에서 추출한 13개의 MFCC coefficient → [Float x 13] 벡터
- 즉, 한 프레임은 13차원의 특징을 가진 1줄짜리 벡터
이렇게 해서 최종적으로 [Float] 형태, 즉 2차원 배열이 만들어집니다.
근데 왜 여기서 하필 0으로 채우냐?
모델은 60개의 프레임이 반드시 있어야 하기 때문입니다.
그런데 만약 녹음이 짧거나, 오디오 처리 도중 프레임 수가 40개밖에 안 나왔다면?
→ 남은 20개 프레임을 만들어줘야 합니다.
하지만 실제 녹음이 없으니 의미 있는 값을 만들 수 없고,
그래서 각 프레임을 [0, 0, 0, ..., 0]으로 채워서 넣는 거죠.
이 과정을 "Padding"이라고 합니다.
0은 아무 특징도 없는 값, 즉 무음이나 비어있는 프레임으로 간주됩니다. 모델 입장에서 보면, "특징이 없는 프레임이 뒤에 좀 붙어 있구나"라고 해석을 할 수 있어요. 만약에 이상한 랜덤 값이나 다른 숫자를 넣게 되면 모델은 이 값이 노이즈라고 인식을 해버리기 때문에 정확도에서 악영향을 끼치게 됩니다.
즉, 0으로 채우는 건 "여기 이후는 유효한 데이터가 아니야. 빈자리만 채운 거야"라고 모델에게 알려주는 신호입니다.
이 처리를 시각적으로 다시 보게 되면?
[
[mfcc1_1, mfcc1_2, ..., mfcc1_13], // 실제 프레임 1
[mfcc2_1, mfcc2_2, ..., mfcc2_13], // 실제 프레임 2
...
[mfcc40_1, mfcc40_2, ..., mfcc40_13], // 실제 프레임 40
[0, 0, ..., 0], // 패딩 프레임 41
...
[0, 0, ..., 0] // 패딩 프레임 60
]
이렇게 표현할 수 있습니다.
정규화 - Feature Scaling (Min-Max Normalization)
MFCC는 로그 스케일로 변환된 주파수 정보를 갖고 있어, 프레임마다 값의 범위가 다르고 스케일이 크거나 작을 수 있습니다.
예를 들어, 어떤 프레임에서는 MFCC의 1번 계수가 0.1에서 0.5 사이에 있고 또 다른 프레임에서는 -2.3에서 1.8까지 넓은 범위를 가질 수 있습니다. 이런 상태로 모델에 입력하면, 학습 당시와 분포가 달라지면서 추론 정확도가 떨어질 수 있습니다.
그래서 이 문제를 해결하기 위해 각 coefficient(열) 단위로 Min-Max 정규화를 적용하여 0~1 범위로 스케일을 맞췄습니다.
정규화된 값 = (현재 값 - 최소값) / (최대값 - 최소값)
이를 통해 각 coefficient가 0~1 범위로 반환되며, 모든 프레임에서 값의 상대적 패턴은 유지한 채 절대 스케일을 일관되게 구성할 수 있었습니다.
Min-Max 정규화는 직관적이고 계산량이 적기 때문에 음성 분석처럼 상대적 패턴이 중요하고, 절대값이 의미 없을 때 적합하다고 합니다. 또한 모바일 온디바이스 환경에서는 메모리 효율과 연산 속도 측면에서도 이점을 가질 수 있습니다.
해당 정규화 과정을 도입한 후 실제 테스트를 해보니 같은 울음소리를 여러 번 분석했을 때, 결과가 더 일관되게 나오고 coefficient 값이 0.2~0.3 수준에서 0.7~0.9 수준으로 향상되었습니다.
즉, 같은 입력에 대해서도 신뢰도 값이 20~30% 수준에서 70%에서 높게는 90%까지 예측이 훨씬 명확하고 일관되게 나타났습니다.
Core ML 모델 추론
전처리 과정을 거쳐서 (13, 60) 크기의 MFCC 특징 벡터를 만들었다면, 이제 이 데이터를 Core ML 모델이 이해할 수 있는 입력 형식으로 변환해서 추론을 수행해야 합니다.
DeepInfant V2 모델은 아래와 같은 입출력을 가지고 있습니다.
모델 입력
- 타입: MLMultiArray
- 크기: (13, 60)
-> 13개의 MFCC coefficient × 60개의 프레임 (시간 축)
모델 출력
- output.target: 분류된 감정 레이블 ("hungry", "tired", "discomfort" 등)
- output.targetProbability: 감정별 확률 값 딕셔너리 [String: Double]
입력 변환 과정
Core ML 모델은 2차원 배열을 직접 받지 못하므로, 먼저 이를 평탄화(flatten) 한 후, MLMultiArray로 변환해야 합니다.
let inputArray = try MLMultiArray(shape: [13, 60], dataType: .float32)
for i in 0..<mfccs.count { // 보통 60
for j in 0..<mfccs[i].count { // 보통 13
let index = i * mfccs[i].count + j
inputArray[index] = NSNumber(value: mfccs[i][j])
}
}
여기서 핵심은 모델이 기대하는 순서대로 데이터를 정확하게 채워 넣는 것입니다.
만약 데이터가 [행, 열]이 아니라 [열, 행] 순서로 들어가면 모델의 분류 정확도가 완전 달라지게 되니 주의해야 합니다.
처음 다뤄보는 기술들이라 아직 미흡한 부분이 많습니다. 잘못된 부분이 있다면 피드백 부탁드립니다!!
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > Swift' 카테고리의 다른 글
[Swift] Tuist 살펴보기 (0) | 2025.04.27 |
---|---|
[Swift] 의존성 주입(Dependency Injection)이란? (0) | 2025.04.18 |
[Swift] Combine에서 map과 flatMap 살펴보기 (2) | 2025.04.08 |
[Swift] Subscript 이해하기 (4) | 2024.12.30 |
[Swift] Actor 이해하기 (2/2) (4) | 2024.12.15 |