Android 메트로놈 타이밍 정확도 개선기: Thread.sleep에서 AudioTrack으로
들어가며
Flutter로 개발한 Android 메트로놈 앱을 출시한 지 두 달이 지났습니다. 앱 출시 초기부터 말썽이었던 메트로놈 박자가 정확하지 않은 문제를 해결한 과정을 기록으로 남깁니다.
문제 상황
증상
특정 subdivision(Triplet, 16th, Sextuplet) 재생 시 타이밍이 불규칙해지는 문제가 발생했습니다.
- Quarter note (subdivision 1): 정상 작동 ✅
- 8th, Triplet, 16th (subdivision 2, 3, 4): 박자가 “절어짐” (불규칙한 간격) ❌
가장 어려웠던 점은 증상을 재현하는 것 자체가 어려웠다는 것입니다. 제대로 동작할 때는 또 제대로 동작했기 때문입니다.
언제 문제가 발생했나?
다음 상황에서 불규칙하게 발생했습니다.
- 배터리 최적화 모드 활성화 시
- 백그라운드 실행 시
- 특정 저사양 기기
- CPU 부하가 높은 상황
- 디버깅에서는 문제가 없지만 프로덕션 배포 시 발생
원인 분석
기존 구현 방식
// Thread.sleep + Spin-wait 방식
while (isPlaying) {
val timeUntilNext = nextBeatTimeNanos - System.nanoTime()
Thread.sleep(sleepTime / 1_000_000L) // 대략적인 대기
// 정확한 타이밍을 위한 Spin-wait
while (System.nanoTime() < nextBeatTimeNanos && isPlaying) {
// CPU를 100% 사용하며 대기
}
playSound()
}
Kotlin핵심 원인: OS 스레드 스케줄링의 불확실성
Android는 실시간 운영체제(RTOS)가 아닙니다. Linux 커널 기반으로
Thread.sleep(10ms)
= “최소 10ms 이상 재우세요”라는 요청- 실제 깨어나는 시점은 스케줄러 재량
- 다른 프로세스, CPU 절전, 열 관리 등 수많은 변수에 영향
1. CPU Governor에 따른 Thread.sleep() 정확도 변화
CPU 모드 | Thread.sleep(10ms) 실제 대기 시간 |
---|---|
Performance Governor | 10-11ms |
Powersave Governor | 12-17ms |
- 배터리 세이버 모드는 강제로 Powersave Governor 활성화
- USB 연결 시 Performance Governor로 전환되어 일시적으로 정상 작동
2. Spin-wait의 CPU 스로틀링
- CPU를 100% 사용하는 busy-wait 패턴은 시스템의 주요 타겟
- 배터리 절약을 위해 OS가 해당 스레드를 강제로 느리게 만듦
- Subdivision이 많을수록 Spin-wait 빈도 증가 → 스로틀링 누적
3. 백그라운드 스레드 우선순위 하락
- 화면 꺼짐 또는 앱 백그라운드 전환 시 스레드 우선순위 자동 조정
- Thread.sleep() 깨어나는 시점이 불규칙해짐
- 정밀한 타이밍이 필요한 오디오에는 치명적
해결 방법 비교
세 가지 접근 방법을 검토했습니다.
항목 | Thread.sleep + Spin-wait | ScheduledExecutorService | AudioTrack (Sample-based) |
---|---|---|---|
타이밍 정확도 | ±2-7ms (환경 의존적) | ±1ms | ±0.023ms |
CPU Governor 영향 | ⚠️ 매우 큼 | ⚠️ 있음 | ✅ 없음 |
배터리 세이버 영향 | ⚠️ 스로틀링 발생 | ⚠️ 약간 발생 | ✅ 없음 |
백그라운드 실행 | ⚠️ 우선순위 하락 | ⚠️ 약간 영향 | ✅ 일관적 |
CPU 사용률 | 높음 (Spin-wait) | 중간 | 낮음 (하드웨어 기반) |
구현 복잡도 | 낮음 | 낮음 | 높음 |
타이밍 메커니즘 | OS 스레드 스케줄러 | OS 스레드 스케줄러 | 오디오 하드웨어 클럭 |
Android 실시간성 | ❌ 보장 안됨 | ❌ 보장 안됨 | ✅ 하드웨어 보장 |
환경별 일관성 | ⚠️ 상황마다 다름 | ⚠️ 약간 차이 | ✅ 항상 동일 |
결론: AudioTrack의 sample-accurate 타이밍이 유일한 근본적 해결책
AudioTrack 구현 원리
왜 AudioTrack인가?
오디오 하드웨어 클럭은
- 44,100Hz로 물리적으로 동작하는 크리스탈 오실레이터
- OS 스케줄러와 완전히 독립적
- 배터리 세이버, 백그라운드 여부와 무관하게 정확
- 1초에 44,100개의 샘플 재생 → 샘플 단위 정밀도 = 0.023ms
핵심 아이디어
// BPM 120, subdivision 1 (quarter note)
val bpm = 120
val beatIntervalSeconds = 60.0 / bpm // 0.5초
// 샘플 단위로 변환
val beatIntervalSamples = (beatIntervalSeconds * 44100).toLong() // 22,050 샘플
// 다음 비트가 재생될 정확한 샘플 위치
var nextBeatSample = 0L
nextBeatSample += beatIntervalSamples // 22,050
nextBeatSample += beatIntervalSamples // 44,100
Kotlin이 방식은 기존에 정상 동작하는 iOS의 AVAudioSourceNode와 동일한 접근입니다.
오디오 렌더링 루프
AudioTrack은 하드웨어가 요청할 때마다 오디오 버퍼를 채우는 방식으로 작동합니다.
val buffer = FloatArray(512 * 2) // Stereo 버퍼
while (isPlaying) {
buffer.fill(0f)
// 버퍼의 각 프레임 처리
for (frameIndex in 0 until 512) {
val currentSample = currentSamplePosition + frameIndex
// 정확한 샘플 위치에서 비트 트리거
if (currentSample >= nextBeatSample) {
triggerBeat()
nextBeatSample += beatIntervalSamples
}
// 재생 중인 사운드를 버퍼에 믹싱
if (soundPlaying) {
buffer[frameIndex * 2] = soundData[position] // Left
buffer[frameIndex * 2 + 1] = soundData[position] // Right
}
}
// 하드웨어에 버퍼 전송 (블로킹)
audioTrack.write(buffer, 0, buffer.size, WRITE_BLOCKING)
currentSamplePosition += 512
}
Kotlin핵심: 하드웨어가 정확히 44,100Hz로 샘플을 소비하므로, 샘플 카운트는 절대 흔들리지 않는 시계 역할을 합니다.
주요 구현 세부사항
1. 오디오 스레드 우선순위 최적화
audioThread = Thread {
// Set audio-optimized thread priority
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
renderAudio()
}.apply {
name = "MetronomeAudioThread"
start()
}
KotlinTHREAD_PRIORITY_URGENT_AUDIO의 이점
- 일반 스레드보다 CPU 시간을 우선 할당
- CPU throttling 최소화
- 배터리 세이버 모드에서도 우선순위 유지
2. 첫 음 잘림 방지
nextBeatSample = 0
으로 즉시 재생 시도 시, 첫 음의 attack 부분이 잘리는 현상이 발생합니다.- 정상: “딱!” (완전한 타격음)
- 잘림: “..악!” (앞부분 10-30ms 누락) 이 현상은
- ✅ 최초 재생 시 항상 발생
- ✅ 정지 후 재생 시 발생
- ❌ pause/resume 시에는 발생하지 않음 (이미 연결된 상태)
- 원인 분석 AudioTrack은
play()
호출 후 실제 재생 준비까지 시간이 필요합니다.
첫 음 잘림 방지 문제 상황
// start() 호출
audioTrack?.play() // +0ms: API 호출
Thread {
renderAudio()
}.start() // +5-15ms: 스레드 시작 // renderAudio() 첫 루프
if (currentSample >= 0) { // 즉시 true
triggerBeat() // soundData[0] 재생 시작
}
audioTrack.write(buffer, …) // +30ms: 첫 버퍼 전송 // 하드웨어 상태
// +0-30ms: DAC 초기화 중, 오디오 라우팅 연결 중
// +30-50ms: 버퍼 전송 중이지만 일부 샘플 손실
// +50ms+: 안정적 재생 시작
Kotlin- 결과: 첫 10-30ms 분량의 샘플(~440-1320 samples)이 하드웨어 초기화 중 손실됩니다.
해결책
100ms 지연 추가
// Fresh start
this.currentSamplePosition = 0
// Give AudioTrack time to initialize (100ms delay for first beat)
this.nextBeatSample = (SAMPLE_RATE * 0.1).toLong() // 4410 samples = 100ms // Start AudioTrack
audioTrack?.play()
Kotlin동작 원리
- t=0ms: audioTrack.play() 호출
- t=0-100ms: 침묵(0.0) 버퍼 전송 → 하드웨어 워밍업
- DAC 초기화
- 오디오 라우팅 확립
- 버퍼 파이프라인 안정화
- t=100ms: triggerBeat() 호출
- t=101ms: soundData[0~] 재생 시작
- ✅ 하드웨어 완전히 준비됨 → 완벽한 재생
즉, 100ms 지연은
- AudioTrack이 하드웨어와 완전히 연결될 시간 확보
- 첫 버퍼가 안정적으로 전달되도록 보장
- 첫 음이 잘리지 않고 깨끗하게 재생
사용자 경험
100ms는 매우 짧은 시간
- 사람의 반응 속도: ~200-300ms
- 100ms는 “거의 즉시”로 느껴짐
- 전문 DAW(Ableton, Logic Pro)도 비슷한 pre-roll 사용
지연 시간에 따른 트레이드오프
방식 | 반응성 | 첫 음 품질 | 전문성 |
---|---|---|---|
즉시 재생 (0ms) | ⭐⭐⭐⭐⭐ | ⭐⭐ (잘림) | ❌ |
100ms 지연 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ (완벽) | ✅ |
pause/resume과의 차이
fun resume() {
// resume은 이미 연결된 상태이므로 50ms면 충분
nextBeatSample = currentSamplePosition + (SAMPLE_RATE * 0.05).toLong()
} fun start() {
// start는 하드웨어 재연결이 필요하므로 100ms 권장
nextBeatSample = (SAMPLE_RATE * 0.1).toLong()
}
Kotlin실전 팁
지연 시간 조정 가이드
- 50ms: 빠른 시작, 일부 기기에서 여전히 잘릴 수 있음
- 100ms: 권장, 대부분 기기에서 안정적, text.md 기준
- 150ms: 매우 안전하지만 지연이 눈에 띌 수 있음
3. Accent/Normal 파일 분리
// WAV 파일을 Float 배열로 로드
val normalSoundData: FloatArray = loadWav("sounds/click.wav")
val accentSoundData: FloatArray = loadWav("sounds/click_accent.wav")
// 비트 트리거 시 선택
val isMainBeat = subdivision == 1 || currentSubBeat == 0
val isAccent = if (subdivision == 1) {
currentBeat == 0 // 1박자만 accent
} else {
isMainBeat // subdivision에서는 모든 메인 비트가 accent
}
val soundData = if (isAccent) accentSoundData else normalSoundData
val volume = if (isAccent) 1.0f else 0.7f
// 볼륨 적용
currentlyPlayingSound = FloatArray(soundData.size) { i ->
soundData[i] * volume
}
Kotlin4. Audio Focus 관리: 전화와의 공존
.setOnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// 전화, 유튜브 등 다른 앱이 오디오 포커스를 가져가면 정지
if (isPlaying) {
stop()
onStopped?.invoke() // Flutter에 정지 이벤트 전송
}
}
}
}
Kotlin5. WAV 파일 로딩의 함정
문제: 고정 offset의 함정
일반적으로 WAV 파일은 44바이트 헤더로 알려져 있지만, 실제로는 다양한 chunk를 포함할 수 있습니다.
RIFF
├─ JUNK chunk (메타데이터, 패딩)
├─ fmt chunk (오디오 포맷 정보)
├─ data chunk (실제 PCM 데이터)
└─ LIST, INFO 등 기타 chunk
Plaintext고정 offset(44)을 사용하면 JUNK chunk가 있는 파일에서 잘못된 위치의 데이터를 읽어 비프음이 발생합니다.
해결: Chunk 기반 동적 파싱
var offset = 12 // "RIFF" + size + "WAVE" 이후부터 시작
while (offset < wavBytes.size - 8) {
val chunkId = readString(wavBytes, offset, 4) // "fmt ", "data", "JUNK" 등
val chunkSize = readInt32(wavBytes, offset + 4)
when (chunkId) {
"fmt " -> {
val fmtData = offset + 8
audioFormat = readInt16(wavBytes, fmtData + 0) // PCM = 1
numChannels = readInt16(wavBytes, fmtData + 2) // Mono=1, Stereo=2
sampleRate = readInt32(wavBytes, fmtData + 4) // 44100
bitsPerSample = readInt16(wavBytes, fmtData + 14) // 16
}
"data" -> {
pcmDataOffset = offset + 8
pcmDataSize = chunkSize
break // 필요한 정보 모두 획득
}
}
offset += 8 + chunkSize // 다음 chunk로 이동
}
KotlinUI 동기화 문제와 해결
AudioTrack으로 전환한 후 새로운 문제가 발생했습니다. 박자를 표시하는 1,2,3,4 UI가 실제 소리보다 약 200ms 빠르게 움직이는 현상이었습니다.
문제 증상
- BPM 120: UI와 오디오가 약간 어긋남
- BPM 200: UI가 1박에서 2박으로 순식간에 점프
- 사용자 체감: “엑센트가 2박에서 울린다”는 착각
원인: AudioTrack의 버퍼 레이턴시
AudioTrack은 Push-based 오디오 시스템입니다.
// 샘플을 미리 버퍼에 쓰기
audioTrack.write(buffer, 0, buffer.size, WRITE_BLOCKING)
// 실제 재생은 200ms 후 (버퍼 크기에 따라 다름)
KotliniOS vs Android 오디오 아키텍처
항목 | iOS (AVAudioSourceNode) | Android (AudioTrack) |
---|---|---|
방식 | Pull-based (Just-in-time) | Push-based (Buffered) |
렌더링 타이밍 | 하드웨어가 요청할 때 렌더링 | 미리 버퍼에 쓰고 재생 대기 |
레이턴시 | ~0ms (즉시 재생) | ~200ms (버퍼 지연) |
근본적 해결: PERFORMANCE_MODE_LOW_LATENCY
audioTrack = AudioTrack.Builder()
.setAudioAttributes(…)
.setAudioFormat(…)
.setBufferSizeInBytes(minBufferSize) // 최소 버퍼
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) // 🔑 핵심
.build()
Kotlin효과
- 버퍼 최적화: 시스템이 내부적으로 버퍼 크기를 최소화
- 하드웨어 Fast Path: 오디오 믹서를 우회하여 직접 하드웨어로 전송
- 레이턴시 감소: 200ms → 10~20ms 수준
- 일관성 향상: 기기별, 상황별 레이턴시 편차 최소화
최종 결과
항목 | 개선 전 | 개선 후 |
---|---|---|
평균 레이턴시 | ~200ms | ~10-20ms |
레이턴시 변동 | ±50ms | ±2ms |
UI/오디오 동기화 | ❌ 눈에 띄게 어긋남 | ✅ 완벽히 일치 |
BPM 200 재생 | ❌ UI 튐 현상 | ✅ 자연스러움 |
UI 애니메이션 최적화
초기 구현에서는 모든 subdivision 비트마다 Flutter 이벤트를 전송했습니다.
- Subdivision 1: 초당 2회 이벤트 (120 BPM)
- Subdivision 4: 초당 8회 이벤트
16th subdivision에서 Flutter UI가 과부하되어 캐릭터 애니메이션이 끊겼습니다.
해결: 메인 비트만 이벤트 전송
// Send beat event to Flutter ONLY on main beats (for UI animation)
// Subdivision beats only trigger sound, not UI updates
if (isMainBeat) {
onBeat?.invoke(currentBeat, currentSubBeat, isAccent)
}
KotlinSubdivision 비트는 소리만 재생, UI 이벤트는 전송하지 않음으로써 이벤트 부하를 1/4로 감소시켰습니다.
iOS와의 구현 비교
항목 | iOS (AVAudioSourceNode) | Android (AudioTrack) |
---|---|---|
타이밍 메커니즘 | 하드웨어 콜백 | 수동 버퍼 채우기 |
파일 로딩 | AVAudioFile 자동 파싱 | 수동 WAV 파싱 |
사운드 믹싱 | 수동 Float 배열 | 수동 Float 배열 |
스레드 관리 | 시스템 자동 | 개발자 직접 관리 |
스레드 우선순위 | 시스템 자동 설정 | THREAD_PRIORITY_URGENT_AUDIO |
Audio Focus | 시스템 자동 처리 | 수동 리스너 + Flutter 연동 |
첫 음 처리 | 시스템 자동 | 100ms 지연 필요 |
구현 복잡도 | 중간 | 높음 |
최종 정확도 | ±0.023ms | ±0.023ms |
결론: Android는 더 많은 수동 작업이 필요하지만, 동일한 정확도를 달성할 수 있습니다.
최종 결과
타이밍 정확도
- Thread.sleep: ±2-7ms (환경 의존적), subdivision 증가 시 오차 누적
- AudioTrack: ±0.023ms (모든 subdivision), 모든 환경에서 동일
환경별 일관성
상황 | Thread.sleep | AudioTrack |
---|---|---|
배터리 세이버 ON | ⚠️ 타이밍 불규칙 | ✅ 정상 |
백그라운드 실행 | ⚠️ 우선순위 하락 | ✅ 정상 |
저사양 기기 | ⚠️ CPU 부하 시 문제 | ✅ 정상 |
USB 연결 시 | ✅ 일시적 정상 | ✅ 정상 |
화면 꺼짐 | ⚠️ 스케줄링 지연 | ✅ 정상 |
전화 수신 | ⚠️ 동시 재생 | ✅ 자동 정지 |
개발 중 참고사항
1. 배터리 세이버 테스트
개발자 옵션에서 “배터리 세이버 모드 강제 활성화” 옵션으로 테스트하세요. 이것이 실제 사용자 환경입니다.
2. 백그라운드 재생 테스트
모든 상황에서 테스트
- 화면 꺼짐
- 다른 앱으로 전환
- 전화 수신 시나리오
- 유튜브 등 미디어 앱 재생
3. 첫 음 재생 확인
초기화 지연을 조정
- 50ms: 빠른 시작, 일부 기기에서 잘릴 수 있음
- 100ms: 안전한 선택, 대부분 기기에서 안정적
- 150ms: 매우 안전하지만 시작이 느리게 느껴질 수 있음
교훈
- Android는 실시간 OS가 아닙니다:
Thread.sleep()
은 “대략적인” 대기일 뿐입니다. 배터리 세이버, 백그라운드 실행, CPU 부하 등 수많은 변수가 스레드 스케줄링에 영향을 줍니다. - 하드웨어 클럭을 활용하세요: 오디오, 센서 등 하드웨어 타임스탬프는 OS 스케줄러보다 정확하고 일관적입니다.
- “정상 작동”은 일시적일 수 있습니다: USB 연결, 개발자 모드 등 특수 상황에서만 잘 작동하는 것은 착각입니다. 실제 사용자 환경(배터리 세이버, 백그라운드)을 반드시 테스트하세요.
- 파일 포맷은 항상 스펙대로만 오지 않습니다: 고정 offset 대신 동적 파싱이 안전합니다. JUNK chunk 하나 때문에 비프음이 날 수 있습니다.
- 이벤트 빈도 최적화: UI 업데이트는 필요한 만큼만, 음향 처리는 백그라운드에서.
- 오디오 전용 API를 활용하세요:
THREAD_PRIORITY_URGENT_AUDIO
, Audio Focus,PERFORMANCE_MODE_LOW_LATENCY
등 Android가 제공하는 오디오 최적화 도구를 적극 활용하세요. - 다른 앱과의 공존을 고려하세요: 전화, 유튜브, 알람 등 다른 오디오 소스와 충돌하지 않도록 Audio Focus를 올바르게 처리해야 합니다.
- 플랫폼 차이를 이해하라: iOS의 Pull-based와 Android의 Push-based는 근본적으로 다른 접근이 필요합니다.
마무리
AudioTrack 구현은 복잡하지만, 프로페셔널 DAW(Ableton, Pro Tools)가 사용하는 동일한 접근입니다. 정확한 타이밍이 필요하다면 하드웨어 클럭만이 답입니다.
iOS는 이미 AVAudioSourceNode로 동일한 방식을 제공하므로, Android도 같은 수준의 정확도를 달성할 수 있었습니다. 다만 Android는 개발자가 직접 더 많은 부분을 관리해야 한다는 차이가 있습니다.