개요
작년 말에 작성했던 이전 포스트의 내용을 기억하실지 모르겠습니다.
적는 와중에도 변명임을 알지만, 올해 상반기는 시작부터 새로운 프로젝트에 투입되어 제 개인 앱 개발에 많은 시간을 투자하지 못했습니다.
물론 아예 손을 놓고 있던건 아닙니다.
여전히 앱의 기획을 전부 말씀드릴순 없지만(하여 통칭 기타 연주 앱으로 뭉뚱그려 설명드립니다), 앱의 핵심 기능을 위한 기반 작업인 실시간 오디오 분석 모듈을 구현하기까지 다양한 시행착오와 고민을 했습니다.
이번 포스트에서는 제가 실시간 오디오 분석 모듈을 구현하며 겪은 경험을 공유하고, 최종적으로 완성된 모듈의 구조를 소개하고자 합니다.
기능 요구사항
거의 반년에 가까운 시간동안 앱의 기획과 컨셉이 바뀌며, 그에 따른 기능의 요구사항도 많이 변화했습니다.
그럼에도 불구하고 앱의 핵심 기능에 대한 요구사항은 유지되었는데,
바로 마이크를 통해 기타 연주의 소리를 수집하고, 이를 분석하여 식별 가능한 데이터로 바꾸는 것입니다.
상술한 내용에서 계속 말씀드렸던 실시간 오디오 분석 기능이 위의 요구사항을 구현한 것입니다.

실시간 오디오 분석 기능으로부터 요구되는 데이터는 다음과 같습니다:
- Pitch(피치, 주파수)
- Amplitude(진폭, 소리의 크기)
- FFT 스펙트럼(주파수 대역별 진폭)
위 세 종류의 데이터는 앱의 모든 핵심 기능들에 필수적이며, Manager(항상 활성화되어 앱 전역 뷰에서 참조 가능한 모듈 레이어) 클래스로 등록되어 앱 전역에서 접근할수 있도록 합니다.
주요 데이터의 활용 예시
Pitch
Pitch-주파수 데이터는 기타 연주의 음계를 식별하는데 필요합니다.
유저가 연주중인 소리가 3옥타브 도인지, 4옥타브 레인지 등등.. 이 데이터를 통해 유저가 연주하는 음계를 식별할 수 있습니다. (이전 포스트에서 주로 다루었던 내용이기도 합니다)
Amplitude
Amplitude-진폭 데이터는 기타 연주의 크기를 식별하는데 필요합니다.
쉽게 말해 볼륨입니다. 단순히 크기만 식별하는걸 넘어, 아주 낮은 진폭으로 감지되는 주변 생활 소음을 필터링한다거나(추후 후술할 캘리브레이션 기능), 주변 소리와 유저의 연주 소리의 대비를 통해 유저가 언제 기타를 연주하는지 에 대한 타이밍 파악도 가능합니다.
FFT 스펙트럼
FFT 스펙트럼(주파수 대역별 진폭)은 기타 연주의 주요 주파수 대역을 식별하는데 필요합니다.
FFT를 쉽게 설명드리자면, 소리를 주파수별로 나눠서 어떤 음이 들어있는지 알아내는 기술로, 이를 통해 단순 피치 검출로는 분석할수 없는 소리의 세부적인 주파수 대역을 파악할 수 있습니다.
예를 들어, FFT 분석을 통해 감지된 주파수 중 C, E, G가 강하게 감지되는 경우, 유저가 C 코드를 타현중이라고 추측할수 있습니다.
추가로, 고음이나 저음의 비율 등을 분석해 현재 연주중인 코드의 분위기도 파악해볼수 있습니다.
시행착오
이처럼 Pitch, Amplitude, FFT 세 가지 데이터를 통해 유저의 연주 음정, 연주 강도, 코드 구성과 같은 정보들을 실시간으로 추론할 수 있습니다.
만약 이 데이터들이 매번 정확하고 일관되게 수집된다면, 이후 기획에 필요한 기능인 코드 감지나 주법 분석도 큰 문제 없이 구축할 수 있었을 것입니다.
하지만 실제로 개발을 진행하면서는 생각보다 많은 시행착오가 있었습니다.
플랫폼별로 오디오 데이터의 해상도나 감지 민감도가 다르고, 동일한 연주 환경에서도 기기마다 결과가 달라지는 등, 다양한 문제들이 모듈 설계를 복잡하게 만들었기 때문입니다.
과정이라 적고 삽질이라 읽는다
1차 구상: AudioKit 기반 iOS 중심 설계
처음에는 iOS 네이티브 영역에서 AudioKit을 활용해, 실시간 오디오 스트림 처리와 고급 분석 기능을 모두 구현하고, Flutter는 단순 UI 렌더링에만 사용하는 구조로 설계했습니다. (이전 포스트에서도 다뤘던 구조입니다.)
하지만 실제 구현 과정에서 메소드 채널을 통해 주고받는 오디오 데이터의 양이 지나치게 많아지며 성능 문제가 발생했고, 이후 안드로이드 영역도 유사하게 개발해야 한다는 점에서 양 플랫폼 모두에서 과도한 네이티브 구현 부담이 예상되었습니다.
결국 개발 효율성이 크게 떨어진다고 판단하여 해당 구조는 폐기했습니다.
2차 구상: iOS 네이티브 앱으로 방향 전환
AudioKit의 분석 정확도와 성능은 뛰어났기 때문에, 아예 Flutter를 제외하고 iOS 네이티브 앱으로 전환하는 방향을 시도했습니다.
하지만 당시에는 지금처럼 환경 기반 진폭 보정(캘리브레이션)에 대한 구상이 없었고, 앱 기획 역시 대중적인 모바일 앱보다는 전문가용 툴에 가까운 방향으로 흐르면서 확장성과 시장성 측면에서 한계를 느꼈습니다. 결국 이 역시 폐기하고 방향을 다시 고민하게 되었습니다.
3차 구상: MacOS 전문가용 툴로 시도
이번에는 오디오 인터페이스 장비를 사용하는 기타 연주 환경을 전제로 하여, MacOS 앱으로의 전환을 고려했습니다. 마이크 입력 대신 오디오 인터페이스를 통해 기타 원음을 받아 분석하는 구조로, 전문가용 기능과 실시간 분석을 목표로 했습니다.
그러나 Swift + MacOS 개발은 러닝 커브가 높았고, 동시에 MacOS라는 플랫폼의 한계(시장성, 배포 범위)와, 여전히 Android 영역에 대한 미련도 남아 있어, 이 구상도 최종적으로는 보류하게 되었습니다.
최종 구상: 네이티브 분석 + Flutter 통합 구조
지금의 최종 구조는 다음과 같습니다:
저수준 오디오 분석은 각 플랫폼의 네이티브 영역에서만 담당하도록 하여, iOS는 AudioKit, Android는 TarsosDSP를 활용했습니다.
분석된 데이터는 Pitch, Amplitude, FFT의 세 가지를 통일된 형태로 정규화한 후, Flutter와 메소드 채널을 통해 전달됩니다.
이후 Flutter에서는 UI 표현뿐만 아니라, 코드 추론, 주법 감지, 리듬 분석 등 고급 분석 기능까지 담당하도록 설계했습니다.
이 구조는 제가 가장 익숙한 Flutter를 중심으로 개발 속도를 유지하면서, 두 플랫폼의 네이티브 장점을 최대한 활용할 수 있는 방향입니다. 책임이 명확히 분리된 구조 덕분에 유지보수와 기능 확장에도 유리하다는 장점이 있습니다.
실시간 오디오 분석

그렇게 최종 구상에 따라 만들어진 실시간 오디오 분석 모듈의 구조는 위와 같습니다.
직전에 설명드린 대로,
마이크 인풋 -> 네이티브 영역의 저수준 분석 처리 -> 정규화된 데이터 전달 -> Flutter 영역에서 고급 분석 처리(및 UI 표현) 순으로 이루어집니다.
(구조도에 존재하는 캘리브레이션 레이어는 추후 포스트에서 상세히 다루겠습니다)
TarsosDSP란?
실시간 오디오 분석 모듈의 핵심인 필수 오디오 데이터(Pitch, Amplitude, FFT)를 수집하는 기능은 네이티브 영역에서 구현됩니다.
iOS 영역에서의 네이티브 구현은 이미 구현해본 경험이 있어 큰 무리가 없었지만, 안드로이드 영역은 새로운 접근이 필요했습니다.
이러한 과정에서 크게 시간을 단축하도록 도와준 라이브러리가 바로 TarsosDSP입니다.
TarsosDSP는 벨기에 겐트대학교의 Joren Six님이 개발/배포한 자바 기반의 오픈소스 오디오 신호 처리 라이브러리로, 피치 감지(YIN 알고리즘), FFT 분석, 진폭 추출 등의 기능을 매우 간단한 인터페이스로 제공합니다.
iOS 생태계의 AudioKit과 유사하게, 복잡한 오디오 처리 로직을 추상화해주며 학술적/실무적으로 모두 검증된 성숙한 라이브러리입니다. (최초 배포부터 무려 10년이나 되었습니다)
자바를 기반으로 하기 때문에 안드로이드 네이티브 영역에 통합하는 것이 수월했고, 별도의 JNI(native wrapper) 없이도 Kotlin(Java와 호환 가능!) 및 Flutter의 MethodChannel을 통해 빠르게 연동할 수 있다는 점도 큰 장점이었습니다.
특히, TarsosDSP는 FFT 스펙트럼과 피치 분석 기능을 매우 유연하게 제공하면서도, 내부에서 각 기능의 샘플링률, 윈도우 타입(Hanning 등), 오버랩 설정 등을 커스터마이징할 수 있어 앱의 목적에 맞게 정밀하게 최적화할 수 있었습니다.
또한, AudioKit과 비교했을 때 한 가지 인상 깊었던 점은, TarsosDSP의 피치 감지 알고리즘이 매우 보수적이라는 점이었습니다.
동일한 환경에서 AudioKit은 잡음에 대해 과도하게 민감하게 반응하는 반면, TarsosDSP는 probability 임계값 기반으로 필터링을 전제로 한 감지 정책을 채택하고 있어, 실제 연주 외의 불필요한 피치 분석을 줄이는 데 유리했습니다.
이는 이후 설명할 정규화 및 캘리브레이션 로직과도 밀접하게 연결됩니다.
네이티브 실시간 오디오 분석 모듈 구현
그럼, 실제 구현을 간략하게 살펴봅시다.
두 네이티브 영역은 최종적으로 정규화된 세 가지 오디오 데이터(Pitch, Amplitude, FFT)를 플러터로 전달하는 역할을 합니다.
각 플랫폼의 오디오 분석 방식과 내부 데이터 포맷은 조금씩 다르지만, 최종적으로 Flutter에 전달되는 데이터는 동일한 구조와 해석 기준을 가져야 합니다.
안드로이드 네이티브 디렉토리 구조 (Kotlin)
android/app/src/main/kotlin/com/example/guitar_app/
├── MainActivity.kt # 앱의 메인 엔트리 포인트
├── TarsosDSPManager.kt # TarsosDSP 관리 클래스
├── core/ # 코어 기능들 모음
│ ├── AudioProcessingManager.kt # 오디오 처리 기본 인터페이스
│ ├── BufferManager.kt # 버퍼 관리 클래스
└── taplevel/ # 핵심 데이터 처리 부분
├── PitchProcessor.kt # 피치 처리 구현
├── AmplitudeProcessor.kt # 진폭 처리 구현
└── FFTProcessor.kt # FFT 분석 구현
IOS 네이티브 디렉토리 구조 (Swift)
ios/Runner/
├── AppDelegate.swift # 앱의 메인 엔트리 포인트
├── Info.plist
└── lib/ # 네이티브 라이브러리
├── Core/ # 코어 기능들 모음
│ ├── AudioManager.swift # AudioKit 관리 클래스
│ ├── AudioProcessingPlugin.swift # 오디오 처리 기본 인터페이스
│ └── BufferManager.swift # 버퍼 관리 클래스
└── TapLevel/ # 핵심 데이터 처리 부분
├── PitchProcessor.swift # 피치 처리 구현
├── AmplitudeProcessor.swift # 진폭 처리 구현
└── FFTProcessor.swift # FFT 분석 구현
서로 다른 공정으로 추출된 원재료라고 해도, 최종 제품이 조립되는 생산 라인에서는 동일한 규격의 부품이 되어야 합니다.
Flutter는 이 부품들을 모아 고차원적 기능을 조립하고 UI로 표현하는 최종 공정을 수행해야 하기 때문에,
양 플랫폼의 네이티브 분석 모듈은 서로 다른 방식으로 데이터를 수집하되, 같은 형태로 가공된 결과물을 제공해야 했습니다.
이러한 이유로, 네이티브 디렉토리 구조 자체도 유사한 책임 분리와 역할 배분을 갖도록 통일성 있게 설계했습니다.
버퍼 및 오디오 설정
TarsosDSPManager.kt
class AudioProcessingManager(context: Context) {
// 컨텍스트 참조
private val contextRef = WeakReference(context)
// 오디오 설정 - iOS의 AudioKit과 일치시킴
private val SAMPLE_RATE = 44100 // 샘플링 레이트
private val BUFFER_SIZE = 4096 // 버퍼 크기 (4096으로 증가)
private val OVERLAP = 0 // 버퍼 오버랩 없음
// 오디오 디스패처 생성 메서드
private fun createAudioDispatcher() {
if (audioDispatcher == null) {
try {
val context = contextRef.get() ?: return
audioDispatcher = AudioDispatcherFactory.fromDefaultMicrophone(
SAMPLE_RATE,
BUFFER_SIZE,
OVERLAP
)
Log.d(TAG, "오디오 디스패처 생성됨, 버퍼 크기: $BUFFER_SIZE, 샘플 레이트: $SAMPLE_RATE")
} catch (e: Exception) {
Log.e(TAG, "오디오 디스패처 생성 실패: ${e.message}")
e.printStackTrace()
}
}
}
// 오디오 처리 시작 - 코루틴 사용
fun startProcessing(): Boolean {
if (isProcessingActive) return true
try {
// 오디오 디스패처 생성
createAudioDispatcher()
// 프로세서 시작
startProcessors()
// 코루틴으로 오디오 처리 시작
processingJob = mainScope.launch {
withContext(Dispatchers.IO) {
audioDispatcher?.run()
}
}
isProcessingActive = true
return true
} catch (e: Exception) {
Log.e(TAG, "오디오 처리 시작 실패: ${e.message}")
return false
}
}
}
AudioManager.swift
class AudioManager: ObservableObject {
// 핵심 설정값
private let minFrequency: Float = 80.0 // 기타 최저 주파수 (E2)
private let maxFrequency: Float = 4800.0 // 기타 최고 주파수 범위
private let samplingRate: Float = 44100.0 // 표준 샘플링 레이트
// 오디오 엔진 및 라우팅
private let engine = AudioEngine()
private var mainMixer: Mixer
private var silenceFader: Fader?
// 신호 분배용 페이더 (각 역할에 맞는 게인 적용)
private var amplitudeFader: Fader? // 진폭 감지용 (gain: 1.0)
private var pitchFader: Fader? // 피치 감지용 (gain: 1.0)
private var fftFader: Fader? // FFT 분석용 (gain: 1.0)
// 페이더 설정 메서드
private func setupFaders(with input: Node) {
amplitudeFader = Fader(input, gain: 1.0) // 진폭 감지용 (기존 10.0에서 조정됨)
pitchFader = Fader(input, gain: 1.0) // 피치 감지용
fftFader = Fader(input, gain: 1.0) // FFT용
}
// 메인 믹서 설정 메서드
private func setupMainMixer(with nodes: [Node]) {
mainMixer = Mixer(nodes)
silenceFader = Fader(mainMixer, gain: 0) // 출력 소리 방지 (처리만 필요)
engine.output = silenceFader
}
// 오디오 세션 설정 - 권한 및 카테고리 설정
private func setupAudioSession(completion: @escaping (Bool) -> Void) {
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
// 권한 획득 시 오디오 세션 카테고리 설정
try AVAudioSession.sharedInstance().setCategory(
.playAndRecord,
options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothA2DP]
)
// 이후 오디오 엔진 시작
}
}
}
위 두 코드는 각 네이티브의 실시간 오디오 분석 모듈의 최상위 매니저 클래스의 일부로, 플러터 앱에서 네이티브 모듈을 호출할 때 각 플랫폼(iOS, Android)의 오디오 엔진을 초기화하고 설정하는 역할을 합니다.
주요 포인트로는, 두 네이티브 모듈이 최대한 비슷한 결과값을 낼 수 있도록 버퍼 사이즈나 샘플링 레이트 등의 설정 값들을 통일하는 것입니다.
샘플링 레이트란 오디오 데이터를 처리하는 데 사용되는 주파수 범위를 의미합니다. 샘플링 레이트는 마치 오디오를 디지털 카메라로 ‘찍는다’고 할 때의 프레임 수와 같습니다. 초당 몇 번 오디오를 잘라서(샘플링해서) 기록하는지를 나타내며, 보통 44,100Hz는 1초에 44,100개의 샘플을 의미합니다.
버퍼 사이즈는 오디오 데이터를 처리하는 데 사용되는 버퍼의 크기를 의미합니다. 이 값이 높을수록 더 많은 데이터를 처리할 수 있지만, 더 많은 메모리와 처리 시간이 필요합니다.
탭 레벨
피치 분석
PitchProcessor.kt
class PitchProcessor(private val dispatcher: AudioDispatcher) {
// 피치 감지 관련 설정
private val MIN_PITCH_HZ = 40.0 // 감지할 최소 피치 (Hz)
private val MAX_PITCH_HZ = 4800.0 // 감지할 최대 피치 (Hz)
private val PROBABILITY_THRESHOLD = 0.05 // 피치 유효성 임계값
// YIN 알고리즘용 버퍼 크기 - iOS와 동일하게 설정
private val BUFFER_SIZE = 4096
// 피치 감지기 - YIN 알고리즘 사용
private val pitchDetector = be.tarsos.dsp.pitch.PitchProcessor(
PitchEstimationAlgorithm.YIN, // YIN 알고리즘 사용
dispatcher.format.sampleRate.toFloat(),
BUFFER_SIZE,
object : PitchDetectionHandler {
override fun handlePitch(result: PitchDetectionResult, event: AudioEvent) {
processPitch(result, event)
}
}
)
// 피치 결과 처리
private fun processPitch(result: PitchDetectionResult, event: AudioEvent) {
// 감지된 피치 값 가져오기
var pitchHz = result.pitch.toDouble()
val probability = result.probability.toDouble()
// 피치가 -1인 경우 처리 (미감지)
if (pitchHz < 0) {
pitchHz = 0.0
}
// 완화된 피치 유효성 검사 로직
// 확률 기반 검사를 완전히 제거하고 주파수 범위만 점검
val isPitchValid = pitchHz >= MIN_PITCH_HZ && pitchHz <= MAX_PITCH_HZ
// 완화된 피치 처리 로직
val finalPitchValue = if (pitchHz > 0) {
if (isPitchValid) {
pitchBuffer.pushValue(pitchHz)
pitchBuffer.getSmoothedValue()
} else {
pitchBuffer.pushValue(0.0)
0.0
}
} else {
pitchBuffer.pushValue(0.0)
0.0
}
// JSON 형식으로 데이터 전송
val jsonData = JSONObject().apply {
put("pitch", finalPitchValue)
put("probability", probability)
put("amplitude", rms)
put("timestamp", currentTime)
}
mainHandler.post {
sink.success(jsonString)
}
}
}
PitchProcessor.swift
class PitchProcessor: AudioProcessor {
// AudioKit의 PitchTap 객체
private var pitchTap: PitchTap?
// 피치 데이터가 업데이트될 때 호출되는 콜백 함수
var onPitchUpdate: (([Float], [Float]) -> Void)?
// PitchTap 설정 및 콜백 초기화
private func setupPitchTap() {
print("PitchTap 설정 시작...")
// 버퍼 크기 4096으로 설정 (안드로이드와 동일)
pitchTap = PitchTap(input, bufferSize: 4096) { [weak self] pitches, amplitudes in
print("피치 데이터 수신: \(pitches.first ?? 0) Hz")
self?.onPitchUpdate?(pitches, amplitudes)
}
print("PitchTap 설정 완료")
}
// 피치 분석 시작
func start() {
print("PitchTap 시작 요청")
pitchTap?.start()
print("PitchTap 시작됨")
}
// 피치 분석 중지
func stop() {
print("PitchTap 중지 요청")
pitchTap?.stop()
print("PitchTap 중지됨")
}
}
양 플랫폼 모두 YIN 알고리즘을 사용하며, 동일한 버퍼 크기(4096)와 유사한 주파수 범위(약 40~4800Hz. 보통의 기타 주파수 범위)를 기반으로 피치를 감지합니다.
그러나 안드로이드는 피치의 신뢰도(probability)에 대한 평가 및 필터링을 명시적으로 구현하며, 평활화를 위한 자체 버퍼도 사용하는 반면, iOS는 AudioKit의 내장 알고리즘에 의존하여 비교적 추상화된 방식으로 처리합니다.
또한, 안드로이드는 JSON 문자열로 직렬화해 데이터를 전송하는 반면, iOS는 콜백 기반 구조로 데이터를 직접 전달합니다.
진폭 분석
AmplitudeProcessor.kt
class AmplitudeProcessor(private val dispatcher: AudioDispatcher) : AudioProcessor {
// 볼륨 버퍼 관리자 (노이즈 감소 및 평활화용)
// 노이즈 게이트 값을 0.01에서 0.001로 낮춤, 스무딩 팩터도 0.3에서 0.2로 완화
private val rmsBuffer = BufferManager(4, 0.001, 0.2)
// dB 범위 설정
private val MIN_DB = -70.0 // 최소 dB (-60에서 -70으로 확장)
// AudioProcessor 인터페이스 구현
override fun process(audioEvent: AudioEvent): Boolean {
// 오디오 버퍼 가져오기
val audioBuffer = audioEvent.floatBuffer
// RMS(Root Mean Square) 계산
val rms = calculateRMS(audioBuffer)
// 버퍼에 추가 (최소한의 평활화만 적용)
rmsBuffer.pushValue(rms)
val smoothedRMS = rmsBuffer.getSmoothedValue()
// dB 계산
val db = calculateDB(smoothedRMS)
// JSON 객체 생성 및 전송
val jsonData = JSONObject().apply {
put("rms", rms)
put("db", db)
put("timestamp", timestamp)
}
mainHandler.post {
sink.success(jsonString)
}
return true
}
// RMS(Root Mean Square) 계산
private fun calculateRMS(buffer: FloatArray): Double {
var sum = 0.0
for (sample in buffer) {
sum += sample * sample
}
return Math.sqrt(sum / buffer.size)
}
// dB(데시벨) 계산
private fun calculateDB(rms: Double): Double {
if (rms < 0.00000001) return MIN_DB
// dB = 20 * log10(rms)
var db = 20 * log10(rms)
// 범위 제한
if (db < MIN_DB) db = MIN_DB
return db
}
}
AmplitudeProcessor.swift
class AmplitudeProcessor: AudioProcessor {
// AudioKit의 AmplitudeTap 객체
private var amplitudeTap: AmplitudeTap?
// 진폭 데이터가 업데이트될 때 호출되는 콜백 함수
var onAmplitudeUpdate: ((Float) -> Void)?
// AmplitudeTap 설정 및 콜백 초기화
private func setupAmplitudeTap() {
print("AmplitudeTap 설정 시작...")
amplitudeTap = AmplitudeTap(input) { [weak self] amplitude in
print("진폭 데이터 수신: \(amplitude)")
self?.onAmplitudeUpdate?(amplitude)
}
print("AmplitudeTap 설정 완료")
}
// 진폭 분석 시작
func start() {
print("AmplitudeTap 시작 요청")
amplitudeTap?.start()
print("AmplitudeTap 시작됨")
}
// 진폭 분석 중지
func stop() {
print("AmplitudeTap 중지 요청")
amplitudeTap?.stop()
print("AmplitudeTap 중지됨")
}
}
두 플랫폼 모두 실시간 RMS 기반의 진폭 데이터를 측정하며, 기본적인 데이터 전송 흐름은 유사합니다.
그러나 안드로이드는 직접 RMS 계산을 수행하고 데시벨(dB) 값으로 변환 후, 일정 범위로 클리핑하여 데이터를 정리하며, 진폭 평활화 역시 별도의 버퍼로 관리합니다.
iOS는 AudioKit의 진폭 측정기를 그대로 활용하면서, 보다 고수준의 API로 간단히 처리합니다.
FFT 분석
FFTProcessor.kt
class FFTProcessor(private val dispatcher: AudioDispatcher) : AudioProcessor {
// FFT 설정 - iOS와 동일하게 설정
private val fftSize = 4096 // FFT 크기
private val sampleRate = 44100f // 샘플 레이트
// 주파수 밴드 설정 (기타 표준 주파수 범위 포함)
private val minFrequency = 40.0 // 최소 주파수
private val maxFrequency = 4800.0 // 최대 주파수
// 출력 비닝 설정
private val binCount = 128 // Flutter로 전송할 주파수 빈 개수
// AudioProcessor 인터페이스 구현
override fun process(audioEvent: AudioEvent): Boolean {
// 오디오 버퍼 가져오기
val audioBuffer = audioEvent.floatBuffer
// FFT 입력 배열 준비
val fftBuffer = FloatArray(fftSize)
System.arraycopy(audioBuffer, 0, fftBuffer, 0,
Math.min(audioBuffer.size, fftSize))
// 윈도우 함수 적용 (해닝 윈도우)
applyHanningWindow(fftBuffer)
// FFT 계산
fft?.forwardTransform(fftBuffer)
// 진폭 스펙트럼 계산 및 주파수 빈으로 그룹화
val fftResult = calculateFrequencyBins(fftBuffer)
// 결과 JSON 객체 생성 및 전송
val jsonData = JSONObject().apply {
put("fft", fftArray)
put("timestamp", timestamp)
put("binCount", binCount)
put("minFreq", minFrequency)
put("maxFreq", maxFrequency)
}
mainHandler.post {
sink.success(jsonString)
}
return true
}
// 윈도우 함수 적용 (해닝 윈도우)
private fun applyHanningWindow(buffer: FloatArray) {
for (i in buffer.indices) {
if (i < fftSize) {
// 해닝 윈도우: 0.5 * (1 - cos(2π*n/(N-1)))
val multiplier = 0.5 * (1 - Math.cos(2 * Math.PI * i / (fftSize - 1)))
buffer[i] = (buffer[i] * multiplier).toFloat()
}
}
}
// 주파수 빈 계산 - 로그 스케일 사용
private fun calculateFrequencyBins(fftBuffer: FloatArray): DoubleArray {
// 진폭 스펙트럼 계산
val amplitudeSpectrum = DoubleArray(fftSize / 2)
for (i in 0 until fftSize / 2) {
val real = fftBuffer[2 * i]
val imaginary = fftBuffer[2 * i + 1]
amplitudeSpectrum[i] = Math.sqrt((real * real + imaginary * imaginary).toDouble())
}
// 로그 스케일 빈으로 그룹화 (기타 음역대 최적화)
// 계산 과정 생략...
return binValues
}
}
FFTProcessor.swift
class FFTProcessor: AudioProcessor {
// FFT 분석을 위한 AudioKit의 FFTTap 객체
private var fftTap: FFTTap?
// FFT 데이터가 업데이트될 때 호출되는 콜백 함수
var onFFTUpdate: (([Float]) -> Void)?
// FFT 처리를 위한 설정값
private let noiseFloor: Float = 0.001 // 노이즈 제거 임계값
private let smoothingFactor: Float = 0.8 // 시간적 스무딩 계수
private var previousFFTData: [Float] = [] // 이전 FFT 데이터 저장
// FFTTap 설정 및 콜백 초기화
private func setupFFTTap() {
print("FFTTap 설정 시작...")
// FFT 크기를 4096으로 설정 (안드로이드와 동일)
fftTap = FFTTap(input, bufferSize: 4096, callbackQueue: .global(qos: .userInteractive)) { [weak self] fftData in
guard let self = self else { return }
// FFT 데이터 처리 (노이즈 제거, 스무딩 등)
let processedData = self.processFFTData(fftData)
// 메인 스레드에서 콜백 호출
DispatchQueue.main.async {
self.onFFTUpdate?(processedData)
}
}
print("FFTTap 설정 완료")
}
// FFT 데이터 향상을 위한 처리 로직
private func processFFTData(_ fftData: [Float]) -> [Float] {
// 노이즈 필터링 및 로그 스케일 변환 적용
var processedData = fftData.map { value in
// 노이즈 제거 (임계값 이하는 0으로)
let filtered = value < noiseFloor ? 0 : value
return filtered
}
// 기타 주파수에 맞게 중요 범위 강조
// 저주파수(80-500Hz) 영역 강화 - 기타 기본 주파수 대역
for i in 2 ..< min(50, processedData.count) {
processedData[i] *= 1.5 // 50% 증폭
}
// 시간적 스무딩 적용
if previousFFTData.isEmpty {
previousFFTData = processedData
} else {
let minSize = min(previousFFTData.count, processedData.count)
for i in 0 ..< minSize {
let smoothed = (smoothingFactor * previousFFTData[i]) +
((1.0 - smoothingFactor) * processedData[i])
processedData[i] = smoothed
}
previousFFTData = processedData
}
return processedData
}
// FFT 분석 시작
func start() {
print("FFTTap 시작 요청")
fftTap?.start()
print("FFTTap 시작됨")
}
// FFT 분석 중지
func stop() {
print("FFTTap 중지 요청")
fftTap?.stop()
print("FFTTap 중지됨")
previousFFTData = []
}
}
양 플랫폼 모두 FFT 크기(4096)와 해닝(Hanning) 윈도우 함수를 사용하며, 기타 주파수 영역에 최적화된 분석을 수행합니다.
하지만 안드로이드는 주파수 빈(bin)을 로그 스케일로 계산하며, 명시적으로 128개로 설정된 분석 결과를 반환합니다.
반면, iOS는 후처리를 통해 저주파 영역을 강조하고, 시각적 부드러움을 위한 시간적 스무딩(계수 0.8)을 적용합니다. 이로 인해 iOS의 FFT 시각화는 좀 더 부드럽게 표현됩니다.
Flutter와 연결, 중앙화
플러터와 통신할 탭 레벨 이벤트 채널 설정
MainActivity.kt
// 메서드 및 이벤트 채널 정의 - app_channel.dart와 일치
private val TARSOS_METHOD_CHANNEL = "com.example.guitar_app/tarsosDSP"
private val TARSOS_PITCH_CHANNEL = "com.example.guitar_app/tarsosDSP/audio_pitch"
private val TARSOS_FFT_CHANNEL = "com.example.guitar_app/tarsosDSP/audio_fft"
private val TARSOS_AMPLITUDE_CHANNEL = "com.example.guitar_app/tarsosDSP/audio_amplitude"
// 이벤트 채널 설정
private fun setupEventChannels(flutterEngine: FlutterEngine) {
// 피치 이벤트 채널
EventChannel(flutterEngine.dartExecutor.binaryMessenger, TARSOS_PITCH_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
Log.d(TAG, "피치 채널 리스너 등록")
tarsosDSPManager.setupPitchSink(events)
}
override fun onCancel(arguments: Any?) {
Log.d(TAG, "피치 채널 리스너 취소")
tarsosDSPManager.setupPitchSink(null)
}
}
)
// FFT 및 진폭 이벤트 채널도 유사하게 설정...
}
AudioProcessingPlugin.swift
// 메서드 및 이벤트 채널 정의 - app_channel.dart와 일치
private static let methodChannelName = "com.example.guitar_app/audio_processing"
private static let pitchChannelName = "com.example.guitar_app/audio_processing/audio_pitch"
private static let fftChannelName = "com.example.guitar_app/audio_processing/audio_fft"
private static let amplitudeChannelName = "com.example.guitar_app/audio_processing/audio_amplitude"
// 플러그인 등록 및 채널 설정
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = AudioProcessingPlugin()
// 메서드 채널 설정
let methodChannel = FlutterMethodChannel(
name: methodChannelName,
binaryMessenger: registrar.messenger()
)
// 이벤트 채널들 설정
let pitchEventChannel = FlutterEventChannel(
name: pitchChannelName,
binaryMessenger: registrar.messenger()
)
// FFT 및 진폭 이벤트 채널도 유사하게 설정...
// 채널 핸들러 등록
methodChannel.setMethodCallHandler(instance.handle)
pitchEventChannel.setStreamHandler(instance)
// FFT 및 진폭 채널도 유사하게 설정...
}
네이티브 단계에서의 마지막 구현은,
구현한 탭 레벨의 코드들을 각 네이티브 플랫폼의 최상위 클래스에 연결하고, 이벤트 채널을 등록하여 데이터가 업데이트 될 때마다 플러터로 전송하는 구조를 만드는 것입니다.
/// 앱에서 사용하는 메소드 채널 상수 정의
class AppChannel {
static const String rootChannel = "com.example.jam_app";
/// 오디오 키트 메인 채널
static const String audioKit = "$rootChannel/audio_processing";
/// TarsosDSP 채널
static const String tarsosDSP = "$rootChannel/tarsosDSP";
/// 피치 데이터 채널
static const String audioPitch = "$audioKit/audio_pitch";
/// FFT 데이터 채널
static const String audioFFT = "$audioKit/audio_fft";
/// 진폭 데이터 채널
static const String audioAmplitude = "$audioKit/audio_amplitude";
}
각 채널 명들은 오타를 방지하기 위해 플러터에서 사용하는 상수로 정의되어 있습니다.
플러터에서 오디오 스트림을 수신하기
audiokit_interface.dart (IOS 네이티브와 통신하는 브릿지)
// 오디오 처리 시작
Future<bool> startAudioProcessing() async {
if (_isProcessing) return true;
try {
debugPrint("$_tag 오디오 처리 시작 요청");
// 마이크 권한 확인
final bool? directPermissionCheck = await _audioProcessingChannel.invokeMethod<bool>('checkMicrophonePermission');
// 권한 확인 후 처리 시작
if (directPermissionCheck == true || _hasMicrophonePermission) {
final Map<String, dynamic> args = {'sessionCategory': 'playAndRecord', 'isActive': true};
final bool result = await _audioProcessingChannel.invokeMethod<bool>('startAudioProcessing', args) ?? false;
_isProcessing = result;
return result;
}
return false;
} catch (e) {
debugPrint("$_tag 오디오 처리 시작 중 오류: $e");
return false;
}
}
// 오디오 데이터 스트림 리스너 설정
void _setStreamListeners() {
// 피치 데이터 채널 리스너
_pitchDataChannel.receiveBroadcastStream({'channel': AppChannel.audioPitch}).listen((event) {
if (event is double) {
_pitchStreamController.add(event);
}
});
// FFT 및 진폭 데이터 채널도 유사하게 설정...
}
tarsos_interface.dart (안드로이드 네이티브와 통신하는 브릿지)
// 오디오 처리 시작
Future<bool> startAudioProcessing() async {
if (_isProcessing) return true;
try {
debugPrint("$_tag 오디오 처리 시작 요청");
// 마이크 권한 확인
final bool? directPermissionCheck = await _methodChannel.invokeMethod<bool>('checkMicrophonePermission');
// 권한 확인 후 처리 시작
if (directPermissionCheck == true || _hasMicrophonePermission) {
final bool result = await _methodChannel.invokeMethod<bool>('startAudioProcessing') ?? false;
_isProcessing = result;
return result;
}
return false;
} catch (e) {
debugPrint("$_tag 오디오 처리 시작 중 오류: $e");
return false;
}
}
// 이벤트 채널 스트림 리스너 설정
void _setStreamListeners() {
// 피치 데이터 채널 리스너
_pitchDataChannel.receiveBroadcastStream({'channel': "${AppChannel.tarsosDSP}/audio_pitch"}).listen((event) {
if (event is double) {
_pitchStreamController.add(event);
} else if (event is num) {
_pitchStreamController.add(event.toDouble());
}
});
// FFT 및 진폭 데이터 채널도 유사하게 설정...
}
드디어 플러터 영역의 코드를 살펴볼 차례입니다.
각 플랫폼의 탭 레벨 분석 모듈 구현을 마치고, 이벤트 채널을 통해 플러터와 연결하는 구조까지 설정되었다면,
이제 Flutter 영역에서는 각각의 플랫폼 네이티브 모듈과 연결된 ~interface.dart 파일들을 통해, 오디오 처리 기능을 직접 호출하고 실시간 분석 데이터를 수신할 수 있게 됩니다.
여기서부터는 본격적으로, 수신된 데이터를 어떻게 해석하고 앱 전반에서 활용할 수 있을지를 살펴보게 됩니다.
플러터 영역에서 오디오 데이터 다루기
audio_manager.dart
// 플랫폼별 스트림 구독 설정
void _subscribeToStreams() {
if (_isIOS && _audioKit != null) {
_pitchSubscription = _audioKit!.pitchStream.listen(_onPitchData);
_amplitudeSubscription = _audioKit!.amplitudeStream.listen(_onAmplitudeData);
_fftSubscription = _audioKit!.fftStream.listen(_onFFTData);
debugPrint("$_tag AudioKit 스트림 구독 완료");
} else if (_isAndroid && _tarsosDSP != null) {
_pitchSubscription = _tarsosDSP!.pitchStream.listen(_onPitchData);
_amplitudeSubscription = _tarsosDSP!.amplitudeStream.listen(_onAmplitudeData);
_fftSubscription = _tarsosDSP!.fftStream.listen(_onFFTData);
debugPrint("$_tag TarsosDSP 스트림 구독 완료");
}
}
// 스트림 데이터 처리 핸들러
void _onPitchData(double pitch) {
// 원시 피치 데이터를 모든 구독자에게 전달
_pitchStreamController.add(pitch);
// 잡음 바닥 통과 여부 확인 및 필터링된 피치 데이터 전달
if (_isAboveNoiseFloor()) {
_filteredPitchStreamController.add(pitch);
} else {
// 잡음 바닥 이하인 경우 0을 전달 (무음으로 처리)
_filteredPitchStreamController.add(0.0);
}
}
// 오디오 처리 시작 (플랫폼 독립적)
Future<bool> startAudioProcessing() async {
try {
bool result = false;
if (_isIOS && _audioKit != null) {
result = await _audioKit!.startAudioProcessing();
_isProcessing.value = _audioKit!.isProcessing;
} else if (_isAndroid && _tarsosDSP != null) {
result = await _tarsosDSP!.startAudioProcessing();
_isProcessing.value = _tarsosDSP!.isProcessing;
}
debugPrint("$_tag 오디오 처리 시작 ${result ? '성공' : '실패'}");
return result;
} catch (e) {
debugPrint("$_tag 오디오 처리 시작 오류: $e");
return false;
}
}
// 오디오 처리 중지 (플랫폼 독립적)
Future<bool> stopAudioProcessing() async {
try {
bool result = false;
if (_isIOS && _audioKit != null) {
result = await _audioKit!.stopAudioProcessing();
_isProcessing.value = _audioKit!.isProcessing;
} else if (_isAndroid && _tarsosDSP != null) {
result = await _tarsosDSP!.stopAudioProcessing();
_isProcessing.value = _tarsosDSP!.isProcessing;
}
debugPrint("$_tag 오디오 처리 중지 ${result ? '성공' : '실패'}");
return result;
} catch (e) {
debugPrint("$_tag 오디오 처리 중지 오류: $e");
return false;
}
}
audio_manager.dart (데이터 수신 및 통합)
// 피치 데이터 처리
void _onPitchData(double pitch) {
_pitchStreamController.add(pitch);
// 필터링 로직 적용
if (_isAboveNoiseFloor()) {
_filteredPitchStreamController.add(pitch);
} else {
_filteredPitchStreamController.add(0.0);
}
}
// FFT 데이터 처리
void _onFFTData(List<double> fft) {
_fftStreamController.add(fft);
// 필터링 로직 적용
if (_isAboveNoiseFloor()) {
_filteredFftStreamController.add(fft);
} else {
final emptyFft = List<double>.filled(fft.length > 0 ? fft.length : 1, 0.0);
_filteredFftStreamController.add(emptyFft);
}
}
// 진폭 데이터 처리
void _onAmplitudeData(double amplitude) {
_currentAmplitude = amplitude;
_amplitudeStreamController.add(amplitude);
// 정규화 처리
if (_calibrator != null) {
final normalizedAmplitude = _calibrator.normalizeAmplitude(amplitude);
_currentNormalizedAmplitude = normalizedAmplitude;
_normalizedAmplitudeStreamController.add(normalizedAmplitude);
}
}
플러터 영역의 audio_manager.dart는 앞서 구현한 네이티브 오디오 분석 모듈로부터 데이터를 수신하고,
이를 앱 전역에 활용 가능한 형태로 재가공하는 실시간 오디오 데이터 처리의 중심 허브입니다.
Flutter는 플랫폼 독립적인 UI 프레임워크지만, 오디오 데이터 처리만큼은 플랫폼별 차이가 크기 때문에
각 네이티브 모듈의 결과를 일관된 인터페이스로 추상화하고 통합 관리할 필요가 있습니다.
이곳에서 다루는 주요 책임은 다음과 같습니다:
- 플랫폼에 맞는 네이티브 인터페이스 초기화
- 네이티브 모듈의 실시간 스트림을 수신하여 처리
- 진폭/피치/FFT 데이터를 필터링 및 정규화하여 앱 내 스트림으로 전송
- 플랫폼 독립적인 API 제공을 통해 뷰(View)나 상태관리 레이어에서는 분기 없는 호출 가능
/// 플랫폼에 맞는 오디오 인터페이스 초기화. onInit 메소드에서 호출됩니다.
Future<void> _initPlatformInterface() async {
if (_isIOS) {
debugPrint("$_tag iOS 플랫폼 감지: AudioKit 인터페이스 초기화");
try {
_audioKit = await Get.putAsync(() => AudioKitInterface().init());
debugPrint("$_tag AudioKit 인터페이스 초기화 완료");
} catch (e) {
debugPrint("$_tag AudioKit 인터페이스 초기화 실패: $e");
}
} else if (_isAndroid) {
debugPrint("$_tag Android 플랫폼 감지: TarsosDSP 인터페이스 초기화");
try {
_tarsosDSP = await Get.putAsync(() => TarsosDSPInterface().init());
debugPrint("$_tag TarsosDSP 인터페이스 초기화 완료");
} catch (e) {
debugPrint("$_tag TarsosDSP 인터페이스 초기화 실패: $e");
}
} else {
debugPrint("$_tag 지원되지 않는 플랫폼: 오디오 인터페이스 초기화 생략");
}
}
오디오 매니저는 앱 구동 시 플랫폼 감지를 통해 적절한 네이티브 인터페이스(AudioKit 혹은 TarsosDSP)를 초기화합니다.
이 과정은 비동기적으로 처리되며, 초기화 성공 여부에 따라 이후의 오디오 처리 흐름이 결정됩니다.
여기서 중요한 점은, 초기화된 오디오 인터페이스는 이후의 모든 오디오 처리 흐름에서 공유된다는 점입니다.
이를 통해 복잡한 상태 분기 없이 단일 진입점(startAudioProcessing, stopAudioProcessing 등)만으로
모든 오디오 처리 흐름을 관리할 수 있게 됩니다.
// TestView.dart
void startAudioProcessing() {
AudioManager.startAudioProcessing(); // 주요 기능을 중앙화하고, 내부에서 플랫폼 분기처리.
}
//이런 식으로 오디오 매니저를 사용할 뷰 레이어에서 굳이 분기처리할 필요가 없습니다.
//void startAudioProcessing() {
// if (_isIOS) {
// _audioKit!.startAudioProcessing();
// } else if (_isAndroid) {
// _tarsosDSP!.startAudioProcessing();
// }
//}
이처럼 AudioManager 내부에서 플랫폼 분기 처리를 완결함으로써, TestView.dart와 같은 뷰(예시입니다) 레이어에서는 단일 메소드 호출만으로 오디오 처리를 시작할 수 있습니다.
Flutter 앱의 구조 특성상, 플랫폼 분기를 UI 레이어에 노출하면 코드 유지보수가 어려워지므로,
중앙에서 책임을 분리한 이 설계는 유지보수성과 재사용성 모두를 높이는 방향입니다.