안드로이드(android.media)에서 제공하는 오디오 데이터 캡처를 위한 클래스
Visualizer를 제대로 사용하려면 AudioSession이 무엇인지 알아야한다.
AudioSessionId는 같은 오디오 스트림을 공유하는 오디오 객체들을 그룹화하는 고유한 식별자(ID)이다.
MediaPlayer, AudioTrack, ExoPlayer 등 안드로이드 앱의 오디오를 다루는 객체는 각각 고유의 오디오 스트림을 갖고, 이 오디오 스트림에 부여된 졍수 식별자가 AudioSessionId 이다
val mediaPlayer = MediaPlayer().apply {
setDataSource("audio.mp3")
prepare()
}
val audioSessionId = mediaPlayer.audioSessionId
이런 식으로, 각 오디오 플레이어 객체는 자신의 AudioSessionId에 접근할 수 있는 방법을 제공한다.
Visualizer는 클래스 생성자로 audioSessionId를 받는다.
Visualizer(int audioSession)
Visuailzer는 오디오 스트림에 대한 두가지의 데이터를 제공한다.
onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate)
오디오 주파수 별 진폭 데이터(FFT, 고속 푸리에 변환을 거친 데이터)를 캡처할때 호출되는 콜백함수
푸리에 변환을 거쳐 변환된 값은 주파수 별 실수+허수로 구성된 복소수 형태다.
이 byte[] fft는 내부적으로 Visualizer.getFft(byte[]) 를 통해 가져온 데이터이고
이 데이터를 어떻게 사용할 것인지를 이 함수 내부에 정의하면 되겠다.
이 함수에 대해서는 아래서 후술하겠다.
onWaveFormDataCapture(Visualizer visualizer, byte[] waveform, int samplingRate)
시간 별로 오디오 파형을 캡처할때 호출되는 콜백함수
각 요소는 -128 ~ 127 의 범위를 가지는 정수이다.
[0, 30, 50, 80, 110, 127, 110, 80, 50, 30, 0, -30, -50, -80 ...]
내부적으로 Visualizer 클래스의 getWaveForm(byte[] waveform) 함수를 호출한다.
위 두 콜백 모두 Visualizer.OnDataCaptureListener 인터페이스에 정의되어 있으며
Visualizer.setDataCaptureListener() 의 파라미터로 함수를 오버라이드 하여 OnDataCaptureListener 객체를 넣어줘야한다.
여기서 주의할 점은 두 콜백의 파라미터인 samplingRate는 이 함수가 오디오 스트림을 캡처하는 횟수가 아니라는 것이다.
현재 재생중인 미디어 파일의 샘플링 레이트를 의미한다.
(나만 헷갈린거면 넘어가자 ㅎㅎ)
오디오 데이터 캡처를 시작하기 전, 캡처 사이즈를 설정해주어야한다.
캡처사이즈는 반드시 2의 배수로 설정해야하는것을 잊지말자.
getCaptureSize / setCaptureSize
이는 Visualizer가 오디오 세션의 데이터를 얼마만큼의 크기로 캡처할지를 결정한다.
예시상황
샘플링 레이트가 48000hz인 오디오 파일을 재생하는 MediaPlayer 가 있고
이에 대한 Visualizer의 캡처사이즈를 1024 로 설정했다고 하자.
캡처사이즈와 onWaveFormDataCapture에서 캡처되는 byte 배열이 무슨 관계가 있나 모르겠다.
이건 문서나 Visualizer 클래스를 잘 뒤져봐도 잘 나오지 않는다.
Visualizer 내부에서 특정 간격을 기준으로 설정한 캡처사이즈 만큼 오디오 데이터를 샘플링하여 가져오는 것으로 보인다.
그래서 오디오 파일의 샘플링 레이트랑은 별 관계가 없는 것 같고
그냥 순간 순간의 PCM 데이터를 캡처사이즈 만큼 긁어서 가져오는 것 같다.
일단 간단한 사전 지식 두개
1) 푸리에 변환으로 시간 도메인 신호를 주파수 도메인 신호로 변환한 데이터를 캡처하는데, 이 데이터는 중간값을 기준으로 양쪽이 대칭이다.
2) 48000hz의 샘플링 레이트를 갖는 데이터의 최고 주파수는 그 절반인 24000hz 이다.
(샤논의 표본화 정리와 나이퀴스트 주파수 참고, 데이터 통신 과목을 수강했다면 슬쩍 들었을 수도)
그리고 위에서 언급했던
onFftDataCapture 내부적으로 FFT 변환된 ByteArray를 가져오는데 쓰이는 getFft()에 대해 알아보자.

Visualizer.getFft(byte[])의 주석이다.
이 함수에서 리턴되는 데이터인 fft는
예시상황을 통해 바로 살펴보면, 저 상황에서 fft 배열은
fft[0] = DC성분의 진폭
fft[1] = 24000hz 대역의 진폭(샘플링 레이트 48000/2)
fft[2] = (48000/1024 x 1) = 46.875hz 대역의 진폭 실수부
fft[3] = (48000/1024 x 1) = 46.875hz 대역의 진폭 허수부
fft[4] = (48000/1024 x 2) = 93.75hz 대역의 진폭 실수부
fft[5] = (48000/1024 x 2) = 93.75hz 대역의 진폭 허수부
...
fft[1022] = (48000/1024 x (1024/2-1)) = 23,953.125hz 대역의 진폭 실수부
fft[1023] = (48000/1024 x (1024/2-1)) = 23,953.125hz 대역의 진폭 허수부
이런 식으로 구성되어 있을 것이다.
WaveForm과 FFT 데이터 중 시각화에 적합한 것을 골라야한다.
내가 하고 싶은 것은 유저가 현재 듣는 사운드에 맞게 원형으로 시각화가 그려지는 것이다.
드럼의 킥이 크게 들리는 순간 시각화의 저음부가 강조되고, 하이햇이 크게 들리는 순간엔 중고음부가 강조되었으면 좋겠다는 생각이다.
사실 실제 써보고 결정하는게 가장 좋겠지만, FFT 데이터를 사용하면 주파수 영역대 별로 나누어 표현할 수 있을 것이다.
라는 게 프로젝트를 진행할때의 생각이었다.
class BaseVisualizer {
private var visualizer: Visualizer? = null
private val _fftFlow = MutableSharedFlow<List<Float>>(replay = 1)
val fftFlow: SharedFlow<List<Float>> = _fftFlow.asSharedFlow()
fun setVisualizer(audioSessionId: Int) {
val visualizer = Visualizer(audioSessionId)
this.visualizer = visualizer
}
fun setVisualizerListener() {
visualizer?.run {
enabled = false
captureSize = CAPTURE_SIZE
setDataCaptureListener(object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
// NOT USED
}
override fun onFftDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
// DATA PROCESS
}
}, Visualizer.getMaxCaptureRate() / 2, true, true)
enabled = true
}
}
fun release() {
visualizer?.release()
visualizer = null
}
}
기본적인 Visualizer의 뼈대 코드이다.
fftFlow 라는 Flow에 캡처한 데이터를 emit 하고, 이를 UI 쪽에서 collect 하여 시각화를 그리는데 쓰게 될 것이다.
setVisualizer() 함수는 파라미터로 오디오 세션의 ID를 받아 캡처 대상을 지정할 것이며,
setVisualizerListener() 는 setDataCaptureListener() 를 통해 각 오디오 데이터가 캡처되면 동작할 콜백을 정의할 것 이다.
setDataCaptureListener(@Nullable OnDataCaptureListener listener,int rate, boolean waveform, boolean fft)
파라미터 중 listener는 위에서 설명했고,
rate는 캡처가 1초에 몇회 실행될지 그 비율이다. 단위는 mHz
위에선 Visualizer.getMaxCaptureRate()의 절반 수준으로 설정했다.
높을수록 캡처를 자주 하고 OnDataCaptureListener의 콜백이 자주 실행될 것이다.
필요에 맞게 설정하자.
boolean waveform, boolean fft 이 두개는 waveform 데이터와 fft 데이터 캡처를 할 것 인지 그 여부이다. 만약 fft 데이터만 필요하다면 waveform = false, fft = true 로 설정하면 된다.
중요한 것은 setDataCaptureListener() 함수를 실행하기전
Visualizer.enable가 false 상태이어야 한다는 것이다.
Visualizer의 사용이 끝났다면 반드시 Visualizer.release()를 호출하여 메모리에서 해제해주어야한다.
그렇지 않으면 메모리 누수가 발생할 수 있다.
FFT 주파수 데이터를 사용하여 시각화를 진행할 때 생길 문제가 있다.
1) 모든 주파수가 필요 없음

대중음악 기준 10Khz가 넘어가는 고주파음이 거의 없다.
사진에서 빨간색은 일반적으로 연주할때, 노란색은 특별한 기술을 써서 더 높은 음을 연주할때 가능한 주파수 범위인데, 대부분 100hz ~ 2Khz 범위이다.
이 글 에 잘 설명되어 있는데, 보통 보컬은 200~800hz, 대부분의 악기가 10Khz 이내이다.
위 글에서도 대중가요는 물론 10Khz 넘어가는 소리도 많이 포함되긴하지만 그 아래 대역과 차이가 꽤나 나는 것을 볼 수 있다.
2) 고주파 대역과의 차이
그 중에서도 고주파 대역은 중-저주파수 대역보다 진폭값이 작을것이다.
시각화 시 고주파 대역은 거의 보이지 않고, 중-저 대역만 강조되면 상당히 이상할 것이다.
적절한 가중치를 부여할 필요가 있다.
문제는 이 대역들 기준을 어떻게 잡을지 모르겠다는 것이다.
프로젝트 진행 시에는 4Khz 까지만 자르고, 대역별 가중치는 그냥 적당히 내 알아서 했다.
지금보니 좀 잘못했다는 감이 있어서 정확한 근거를 찾아 다시 조정할 필요가 있을 듯 하다.
아무튼 오디오 데이터에 적절한 전처리를 할 필요가 있으므로, 이를 수행할 클래스를 정의하자.
class AudioDataProcessor {
// 주파수 필터링
fun filterFftData(
audioData: List<Float>,
samplingRate: Int, // 48000
captureSize: Int, // 1024
minFreq: Int, // 40
maxFreq: Int // 4500
): List<Float> {
val resolution = (samplingRate / captureSize).toDouble()
val startIndex = (minFreq / resolution).toInt()
val endIndex = (maxFreq / resolution).toInt()
return audioData.slice(startIndex..endIndex)
}
/* 주파수 대역별 가중치 */
fun scaleFrequencies(audioData: List<Float>): List<Float> {
val size = audioData.size
return audioData.mapIndexed { index, value ->
val scaleFactor = when {
index < size / 4 -> 1.0f // 저주파 대역
index < size / 2 -> 1.0f // 중간 대역
else -> 3.0f // 고주파 대역
// 값은 무시하세요. 정확하지 않아요
}
value * scaleFactor
}
}
/* 로그 스케일 */
fun applyLogScale(audioData: List<Float>): List<Float> {
val epsilon = 1e-6f // 0 방지용 작은 값
val minValue = audioData.minOrNull() ?: 0f
val offset = if (minValue < 1f) 1f - minValue else 0f // 최소값을 1로 이동
return audioData.map { value ->
val shiftedValue = value + offset + epsilon // 데이터를 양수 범위로 이동
log10(shiftedValue) // 로그 변환
}
}
/* 다이나믹 레인지 압축 */
fun compressDynamicRangeRoot(audioData: List<Float>): List<Float> {
return audioData.map { kotlin.math.sqrt(it) }
}
/* 0.0 ~ 1.0 사이 정규화 */
fun normalize(audioData: List<Float>): List<Float> {
val max = audioData.maxOrNull() ?: 1f // 데이터 최대값
val min = audioData.minOrNull() ?: 0f // 데이터 최소값
return if (max - min <= 1f) List(audioData.size) { 0f } else audioData.map { (it - min) / (max - min) }
}
}
모든 전처리 함수를 다 쓰진 않지만 일단 다 넣어놨다.
프로젝트 진행 시에는 주파수 필터링 이후 정규화만 적용하여 사용하였다.
다음 글에선 정확한 전처리 과정과 onFftDataCapture() 의 구현을 정리할 예정이다.
https://developer.android.com/reference/android/media/audiofx/Visualizer