e75f9c66 기준 AudioRecord/음성로깅 리스크 리뷰
기준 커밋: e75f9c66a430dcb52b87e124e634a917458580f0
기준 커밋 제목: [SILOGOOD] fix getAirClearAutoStartModeEnable
이 문서는 e75f9c66을 "음성로깅 도입 전후를 나누는 기준점"으로 사용한다. e75f9c66 자체는 음성로깅 커밋이 아니며, 변경 범위도 FunctionCallHandler.kt의 공기청정 자동시작 조회 로직이다.
한 줄 결론
STT 입력 불능과 AudioRecord 크래시 가능성은 "전부 음성로깅 때문"이라고 보면 부정확하다.
더 정확한 분리는 다음과 같다.
e75f9c66 이전부터 존재한 구조 리스크:
AudioRecord read loop, isRecording, requestAudioRecord, 언어변경 상태 복구가 어긋날 수 있는 잠재 리스크가 있음.
다만 기존 서비스 시나리오에서는 상태 gate가 대부분 막고 있어 실제 발생 가능성은 낮았던 것으로 보는 게 맞음.
e75f9c66 이후 voice_logging 계열에서 추가 또는 증폭된 리스크:
pre-F2/post-WUW/S3 업로드/always-on 성격의 수집이 들어오면서 위 구조 리스크가 더 자주, 더 치명적으로 드러날 수 있음.
직접 버그로 확인된 부분:
Never stop recording 계열은 AudioRecord tombstone과 직접 연결될 수 있어 revert 유지가 맞음.
Triple pool 재사용은 음성로깅 경로의 buffer ownership을 깨뜨릴 수 있었고, 제거하는 방향이 맞음.
isRecording 참조 지도
현재 서비스 경로에서 문제가 되는 isRecording은 AudioRecordManager 내부의 AtomicBoolean이다.
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
private val isRecording = AtomicBoolean(false)
app/src/main/java/com/skmagic/ondeviceai/agent/test/MainActivity.kt에도 같은 이름의 Boolean이 있지만, 이는 테스트 UI용 별도 상태라 서비스 녹음 문제 분석에서는 제외한다.
1. 원본 상태 보유: AudioRecordManager
| 위치 | 용도 | 문제 관점 |
|---|---|---|
AudioRecordManager.kt:61 |
isRecording 원본 AtomicBoolean |
모든 서비스 경로가 이 값을 기준으로 녹음 여부를 판단 |
AudioRecordManager.kt:107 requestAudioRecord() |
녹음 중 owner 충돌 확인 | isRecording=true가 남으면 재초기화/owner 확보 판단에 영향 |
AudioRecordManager.kt:168 startRecording() |
getAndSet(true)로 중복 start 방지 |
이미 true면 새 read loop를 만들지 않고 성공처럼 반환 |
AudioRecordManager.kt:226 stopRecording() |
getAndSet(false)로 stop 상태 전환 |
정상 F3/stop 경로에서는 여기서 내려감 |
AudioRecordManager.kt:371 processAudioData() |
while (isActive && isRecording.get()) read loop 조건 |
이 값이 true인 동안 AudioRecord.read loop 유지 |
AudioRecordManager.kt:472 isRecording() |
외부 공개 getter | MyAccessibilityService/ForegroundService가 이 getter를 사용 |
AudioRecordManager.kt:514 cleanup() |
false일 때만 리소스 정리 | true가 남으면 cleanup이 AudioRecord 해제를 건너뛸 수 있음 |
정리하면 AudioRecordManager 내부에서 isRecording은 두 가지 의미를 동시에 가진다.
1. 논리적 녹음 상태
2. read loop 생존 조건
그래서 이 값이 실제 read loop와 어긋나면 문제가 된다.
실제 read loop: 죽음
isRecording: true
이 경우 외부에서는 "이미 녹음 중"으로 보고 새 녹음을 열지 않을 수 있다.
2. F1/F2 시작 gate: MyAccessibilityService
MyAccessibilityService는 사용자의 F1/F2 입력을 실제 녹음 시작으로 연결하는 앞단이다.
| 위치 | 시나리오 | 동작 |
|---|---|---|
MyAccessibilityService.kt:242 |
F1 멀티턴 | !AudioRecordManager.isRecording()일 때만 start |
MyAccessibilityService.kt:255 |
F1 일반 IDLE -> PRE_RECORDING | isRecording=false이고 DeviceStatus=IDLE일 때만 start |
MyAccessibilityService.kt:296 |
F2 TTS 중단 후 재녹음 | 현재 isRecording 로그 출력 |
MyAccessibilityService.kt:298 |
F2 TTS 중단 후 재녹음 | isRecording=false이면 PRE_RECORDING start |
MyAccessibilityService.kt:308 |
F2 IDLE 처리 | 현재 isRecording 로그 출력 |
MyAccessibilityService.kt:310 |
F2 IDLE -> PRE_RECORDING | isRecording=false이고 DeviceStatus=IDLE일 때만 start |
이 경로가 실제 사용자 체감과 가장 직접적으로 연결된다.
isRecording=false:
F1/F2가 새 녹음을 열 수 있음.
isRecording=true:
F1/F2가 "이미 녹음 중"으로 보고 start를 건너뛸 수 있음.
따라서 isRecording=true가 잘못 남는 문제는 주로 MyAccessibilityService의 F1/F2 start gate에서 STT 입력 불능처럼 보인다.
3. release 정리 gate: ForegroundService
| 위치 | 용도 | 문제 관점 |
|---|---|---|
ForegroundService.kt:904 releaseSpeech() |
서비스 해제 시 녹음 중이면 stopContinuousRecording() 호출 |
사용자 입력 gate는 아니고 release/cleanup 정리용 |
이 경로는 F1/F2 입력을 막는 위치는 아니다.
다만 isRecording이 true로 남아 있으면 release 시 stop 경로를 타게 되고, false면 바로 cleanup/unregister로 내려간다.
4. 분석에서 제외할 isRecording
| 위치 | 이유 |
|---|---|
app/src/main/java/com/skmagic/ondeviceai/agent/test/MainActivity.kt |
테스트 UI용 local Boolean. 서비스 AudioRecordManager.isRecording()과 다른 상태 |
5. 결론
현재 문제 분석에서 핵심 연결은 아래다.
AudioRecordManager.isRecording 원본
-> AudioRecordManager.processAudioData() read loop 조건
-> MyAccessibilityService F1/F2 start gate
그래서 수정이나 방어 보강을 한다면 우선순위는 다음 순서가 맞다.
1. AudioRecordManager에서 read loop 종료와 isRecording 상태가 어긋나지 않게 함
2. start/stop 경계에서 read loop generation 또는 job identity 확인
3. MyAccessibilityService는 isRecording만 믿지 않고 DeviceStatus와 함께 판단 유지
4. ForegroundService release 경로는 정리 보조 경로로 유지
기준 커밋의 의미
e75f9c66은 아래만 바꿨다.
FunctionCallHandler.isAirClearAutoStartModeEnabled()
바뀐 내용:
기존: GET_AIR_CLEAR_AUTO_START_MODE_ENABLE 결과가 애매하면 getAirCleanerOperation().autoStart fallback 사용
변경: getSingleData(GET_AIR_CLEAR_AUTO_START_MODE_ENABLE, ENABLE_KEY).isTrue() 사용
따라서 이 커밋 자체는 아래 흐름과 직접 관련이 없다.
AudioRecordManagerForegroundService.onAudioBufferReceivedinitializeSpeechRecognitionstartRecordingstopRecordingVoiceRecognitionStatusStore- pre-F2/post-WUW 음성 로깅
- S3 업로드
서비스 환경 기준 판단
기존 서비스 환경에서 이슈가 없었다면 그 판단은 타당하다.
이 문서에서 read loop 종료 후 isRecording=true, requestAudioRecord owner 초기화 순서, 언어 변경 finally START는 직접 장애 원인으로 확정한 항목이 아니다.
해당 항목들은 정상 사용자 시나리오에서는 아래 gate로 대부분 막힌다.
F1/F2/F3 진입 전 VoiceRecognitionStatusStore 확인
CHANGE_LANGUAGE는 IDLE/PRE_RECORDING에서만 허용
정상 F3/stopRecording 경로에서 isRecording=false 정리
기기 상태 callback으로 음소거/하이나무 사용안함 상태 재동기화
따라서 이 항목들은 잠재 리스크이며, 릴리즈 차단 항목이라기보다 수정하면 좋음 수준의 방어 보강으로 분류한다.
반대로 voice_logging 이후 직접 확인해야 하는 핵심은 아래다.
Never stop recording 계열이 다시 들어가지 않았는가
callback buffer ownership이 명확한가
Triple pool 같은 immutable object 재사용이 남아 있지 않은가
음성 로그 실패가 STT 기능 실패로 전파되지 않는가
현재 음성 입력 흐름
F1/F2/F3
-> MyAccessibilityService
-> VoiceRecognitionStatusStore 검사
-> DeviceStatusStore 검사
-> ForegroundService.startRecording/stopRecording
-> SpeechRecognitionService.startRecognition/stopRecognition
-> AudioRecordManager.startRecording/stopRecording
-> AudioRecord.read loop
-> ForegroundService.onAudioBufferReceived
-> STT stream + pre/post WUW logging buffer
이 흐름에서 STT 입력이 안 되는 경우는 크게 두 갈래다.
상태 gate에서 막힘:
VoiceRecognitionStatusStore 또는 DeviceStatusStore가 START/IDLE/RECORDING 상태가 아님.
AudioRecord 상태가 꼬임:
앱은 isRecording=true라고 믿지만 실제 read loop가 없거나, AudioRecord 재초기화가 실패함.
리스크 A: read loop 종료 후 isRecording=true 잔류
분류: e75f9c66 이전부터 가능한 구조 리스크
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/MyAccessibilityService.kt
1. 현상
사용자는 F1/F2를 눌렀는데 STT 입력이 시작되지 않는 것처럼 보인다.
로그상으로는 AudioRecordManager.isRecording()이 true라서 MyAccessibilityService가 새 녹음을 시작하지 않을 수 있다.
2. 원인
AudioRecordManager.processAudioData()의 read loop가 AudioRecord.ERROR_INVALID_OPERATION 또는 AudioRecord.ERROR_BAD_VALUE에서 break로 끝날 수 있다.
그런데 loop 종료 시점에 isRecording.set(false)가 보장되지 않는다.
3. 분석
현재 구조는 다음 상태를 만들 수 있다.
실제 AudioRecord read loop: 종료됨
AudioRecordManager.isRecording(): true
이때 MyAccessibilityService는 아래 조건 때문에 새 녹음을 시작하지 않는다.
if (!AudioRecordManager.isRecording()) {
startRecording(...)
}
즉 "녹음 중이라고 믿지만 실제 입력 loop는 없는 상태"가 된다.
이 문제는 음성로깅 이전에도 존재 가능한 AudioRecord lifecycle 문제다. 다만 음성로깅 이후에는 항상 오디오 callback을 더 오래 쓰므로 노출 가능성이 커진다.
4. 대처방안
processAudioData() 종료 경로에 read loop 종료 상태를 반영해야 한다.
권장 방향:
read loop가 정상 종료/에러 종료되면 isRecording=false로 정리
단, 새 recordingJob과 오래된 recordingJob이 교차하지 않도록 job identity를 확인
예상 수정 포인트:
AudioRecordManager.processAudioData()
AudioRecordManager.startRecording()
AudioRecordManager.stopRecording()
리스크 B: requestAudioRecord() owner 초기화 순서
분류: e75f9c66 이전부터 가능한 구조 리스크
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.kt
1. 현상
언어 변경 또는 STT 재초기화 이후 SpeechRecognitionService.initialize()가 실패할 수 있다.
그 결과 F1/F2는 들어오지만 실제 AudioRecord owner가 정상 복구되지 않아 STT 입력이 되지 않을 수 있다.
2. 원인
requestAudioRecord()가 시작하자마자 currentClientId = null을 수행한다.
그 다음 바로 isRecording과 currentClientId를 비교한다.
currentClientId = null
if (isRecording.get() && currentClientId != clientId) {
return false
}
3. 분석
isRecording=true가 남은 상태에서 언어 변경 재초기화가 들어오면 다음 순서가 가능하다.
1. isRecording=true 잔류
2. initializeSpeechRecognition()
3. SpeechRecognitionService.initialize()
4. AudioRecordManager.requestAudioRecord(SERVICE_ID, this)
5. currentClientId=null
6. isRecording=true && null != SERVICE_ID
7. requestAudioRecord() 실패
이 경우 앱은 이후 상태를 IDLE/START로 복구하더라도 실제 AudioRecord 등록은 실패했을 수 있다.
4. 대처방안
currentClientId를 먼저 지우지 말고 기존 owner를 확인한 뒤 성공 시점에 갱신해야 한다.
권장 방향:
기존 owner 확인
필요 시 명시적 stop/release
AudioRecord 확보 성공
callback 등록 성공
마지막에 currentClientId=clientId
리스크 C: 언어 변경 finally의 무조건 START 복구
분류: e75f9c66 이전부터 이어진 상태 설계 리스크
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.ktapp/src/main/java/com/skmagic/ondeviceai/agent/common/VoiceRecognitionStatusStore.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/DeviceCommunicator.kt
1. 현상
하이나무 사용안함 상태에서 언어 설정 변경 후 음소거 해제 같은 상태 전환을 하면 STT가 예상대로 열리지 않거나, 반대로 열리면 안 되는 상태에서 열린 것처럼 보일 수 있다.
2. 원인
언어 변경 완료 finally에서 실제 기기 상태를 다시 평가하지 않고 아래처럼 앱 내부 음성 인식 상태를 START로 둔다.
VoiceRecognitionStatusStore.setStatus(VoiceRecognitionStatus.START)
3. 분석
VoiceRecognitionStatusStore는 세 축을 가진다.
privacyStatus
homeLockStatus
status
하지만 아래 상태들은 여전히 status 하나를 공유한다.
- 하이나무 사용안함
- 음소거
- 음성 볼륨 0
- LLM STOP/PAUSE/START
- 언어변경 중 임시 STOP
- 언어변경 완료 후 START
즉 언어 변경이 끝났다는 이유만으로 START를 넣으면 실제 기기 설정과 앱 내부 상태가 어긋날 수 있다.
완전 이전 버전에서는 VoiceRecognitionStatusStore.status 하나만 있었고, 이후 privacy/homeLock만 분리됐다. 음소거/하이나무 사용안함/언어변경 임시 상태는 지금도 같은 _status 축을 공유한다.
4. 대처방안
언어 변경 완료 시점에 실제 기기 상태를 다시 읽고 판단해야 한다.
재조회 대상:
getLockModeEnable
getPrivacyModeEnable
getSoundMute
getVoiceRecognitionEnable
getSoundVoiceVolume
권장 방향:
언어 변경 성공/실패와 별개로 getSpeechRecognitionStatus() 재평가
실제 사용 가능할 때만 START
그 외는 STOP 유지
추가로 getSpeechRecognitionStatus()가 현재 private라서 ForegroundService에서 재사용하기 어렵다. 재사용 가능한 public/internal API로 분리하는 편이 좋다.
리스크 D: 초기화 실패 후 IDLE/START처럼 보이는 문제
분류: e75f9c66 이전부터 가능한 구조 리스크
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/SpeechRecognitionService.ktapp/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
1. 현상
언어 변경 후 화면/상태는 정상처럼 보이지만 실제 STT 입력이 되지 않는다.
2. 원인
언어 변경 중 아래 초기화가 실패해도 finally에서 상태를 복구한다.
initializeSTT()
initializeSpeechRecognition()
initTts()
initializeAudioManager()
3. 분석
가능한 상태:
recognizer 또는 AudioRecord 등록: 실패
DeviceStatusStore: STATE_IDLE
VoiceRecognitionStatusStore: START
이 상태에서는 F1/F2 gate는 통과하지만 실제 STT stream 또는 AudioRecord callback은 동작하지 않을 수 있다.
특히 SpeechRecognitionService.initialize()는 AudioRecordManager.requestAudioRecord() 실패 시 로그만 남기고, 상위 initializeSpeechRecognition()은 실패 여부를 명시적으로 판단하지 않는다.
4. 대처방안
초기화 함수들이 성공/실패를 반환하게 하고, 실패 시 IDLE/START로 복구하지 않아야 한다.
권장 방향:
initializeSpeechRecognition(): Boolean
AudioRecord 등록 실패 시 STATE_INIT_NOW 또는 ERROR 상태 유지
LLM status도 IDLE 대신 init failed 계열로 전달
리스크 E: voice_logging 이후 pre-F2/post-WUW 수집으로 AudioRecord 사용 강도 증가
분류: e75f9c66 이후 voice_logging 계열에서 추가 또는 증폭
관련 커밋:
02ccc4f2 Add pre recording for "HI NAMUH" speech241ba5e2 Upload pre recording audio to S3143b647a Add S3 file uploader for pre/post wuw wav file (test complete)858a406d Never stop recording066a107d Revert "Never stop recording"
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/DeviceCommunicator.kt
1. 현상
음성로깅 도입 후 AudioRecord callback이 STT뿐 아니라 pre-F2 ring, post-WUW buffer, S3 업로드용 원본 데이터 수집까지 함께 담당한다.
정상 동작에서는 사용자가 느끼는 기능 변화가 거의 없어야 한다.
하지만 AudioRecord 쪽에 stop/start 타이밍 문제나 buffer 반환 문제가 있으면, 예전에는 STT 입력 문제로만 보이던 것이 이후에는 아래처럼 같이 보일 수 있다.
STT 입력 안 됨
pre-F2 또는 post-WUW 음성 로그가 비거나 꼬임
S3 업로드 로그가 누락됨
AudioRecord buffer pool이 빨리 고갈됨
2. 원인
onAudioBufferReceived()가 한 번의 callback에서 다음을 모두 처리한다.
ShortArray -> FloatArray 변환
pre-F2 ring append
MAIN_RECORDING이면 post-WUW append
STT stream 저장
buffer 반환
그리고 녹음 종료/F2 시점에는 아래 동작이 추가된다.
F2 진입: pre-F2 ring drain -> WAV encode -> S3 URL 요청 -> S3 PUT
실제 stop: post-WUW buffer drain -> WAV encode -> S3 URL 요청 -> S3 PUT
현재 코드는 업로드 자체는 voiceUploadExecutor 단일 백그라운드 스레드에서 수행한다. 그래서 S3 네트워크 대기 자체가 AudioRecord read thread를 직접 막는 구조는 아니다.
리스크의 핵심은 업로드 thread가 아니라, 업로드할 원본 샘플을 만들기 위해 AudioRecord callback에서 더 많은 상태와 buffer를 같이 만지게 됐다는 점이다.
3. 분석
음성로깅 자체가 항상 문제는 아니다.
정상 조건에서는 아래처럼 동작해야 한다.
AudioRecord read loop는 하나만 유지
callback은 받은 ShortArray를 FloatArray로 변환
pre-F2 ring에는 최근 4초만 유지
MAIN_RECORDING 동안 post-WUW buffer에 발화 전체 저장
STT stream write와 로깅 append가 모두 끝나면 원본 buffer 반환
S3 업로드는 별도 executor에서 비동기로 처리
이 구조 자체는 기능 목표와 맞다. F2 이전 발화를 남기려면 pre-F2 ring이 필요하고, WUW 이후 실제 명령 발화를 남기려면 post-WUW buffer가 필요하다.
문제는 voice_logging 이후에 AudioRecord callback의 책임이 아래처럼 바뀐 것이다.
| 구분 | voice_logging 이전 | voice_logging 이후 |
|---|---|---|
| AudioRecord callback 목적 | STT 입력 전달 중심 | STT 입력 + pre-F2 보존 + post-WUW 보존 |
| buffer 사용 범위 | STT stream 처리 후 반환 | STT/로깅 양쪽 처리 후 반환 |
| stop/F2 부가 동작 | 녹음 상태 전환 중심 | ring/buffer drain과 S3 업로드 예약 추가 |
| 실패 시 영향 | STT 입력 문제 중심 | STT 입력 + 음성 로그 누락/꼬임 동시 발생 가능 |
따라서 voice_logging은 기존 구조 리스크를 새로 만든 원인이라기보다, 기존 리스크가 실제 문제로 보일 수 있는 표면적을 넓힌 변경이다.
예를 들어 read loop가 하나만 정상 유지되면 큰 문제가 없지만, 상태 전환이 꼬이면 STT 입력 문제와 로깅 데이터 꼬임이 동시에 발생할 수 있다.
특히 아래 세 조건이 겹칠수록 위험이 커진다.
1. F1-F3-F2가 짧은 간격으로 반복됨
2. AudioRecord stop/start가 완전히 직렬화되지 않음
3. callback buffer ownership이 명확하지 않음
이 때문에 Never stop recording 변경은 위험했다. pre-F2 데이터를 더 안정적으로 얻으려는 의도는 이해되지만, 실제 하드웨어 stop을 생략하면 AudioRecord 내부 buffer 상태와 앱의 논리 상태가 분리될 수 있다. tombstone의 releaseBuffer/mUnreleased 계열 문제는 이 축과 직접 맞닿아 있다.
반대로 현재처럼 audioRecord.stop()을 복구하고, callback buffer를 각 consumer가 finally에서 반환하는 방향은 voice_logging 기능을 유지하면서 리스크를 줄이는 방향이다.
4. 대처방안
STT 입력과 로깅 버퍼의 책임을 명확히 분리해야 한다.
권장 방향:
AudioRecord callback ownership은 하나로 유지
STT stream write와 로깅 buffer append는 같은 callback 안에서 하되 실패 격리
로깅 실패가 STT buffer 반환 또는 STT 입력을 막지 않게 처리
S3 URL 요청/PUT은 지금처럼 callback 밖 executor에서만 수행
pre-F2 ring은 bounded buffer 유지
post-WUW buffer는 stop/F3/cancel 경계에서 명확히 drain 또는 clear
사양 관점에서 정리하면 다음이다.
voice_logging의 목표:
F2 전후 음성을 남겨 STT/LLM/필드 이슈 분석 evidence를 확보한다.
지켜야 할 전제:
AudioRecord read loop는 하나여야 한다.
start/stop은 직렬화되어야 한다.
음성 로그 실패가 STT 기능 실패로 전파되면 안 된다.
buffer 반환은 STT 성공/실패, 업로드 성공/실패와 무관하게 보장되어야 한다.
즉 voice_logging 기능은 유지하되, AudioRecord lifecycle과 buffer ownership을 더 보수적으로 가져가는 것이 맞다.
리스크 F: Never stop recording으로 하드웨어 상태와 논리 상태 분리
분류: e75f9c66 이후 voice_logging 계열에서 추가됐다가 현재 revert됨
관련 커밋:
858a406d Never stop recording066a107d Revert "Never stop recording"
근거 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
1. 현상
취약 버전에서는 F1/F3/F2 타이밍에서 AudioFlinger tombstone이 발생했다.
대표 시그니처:
releaseBuffer: mUnreleased out of range
AudioRecord.read
AudioRecordManager.processAudioData
SIGABRT
2. 원인
Never stop recording 변경에서 stopRecording() 안의 실제 하드웨어 stop이 빠졌다.
// audioRecord?.stop()
논리 상태는 isRecording=false가 되지만, 하드웨어 capture와 내부 buffer 상태는 계속 영향을 받을 수 있다.
3. 분석
이 변경은 음성로깅의 pre-F2 데이터를 확보하려는 목적과 연결된다. 하지만 Android AudioRecord는 read loop와 stop/start 경계가 명확해야 한다.
논리 상태와 하드웨어 상태가 분리되면 다음 문제가 생긴다.
read loop 종료 전 다음 startRecording 진입
AudioRecord 내부 buffer backlog 누적
releaseBuffer/mUnreleased 불일치
현재 HEAD에서는 066a107d로 revert되어 audioRecord?.stop()이 복구되어 있다.
4. 대처방안
현재처럼 하드웨어 stop을 복구한 방향은 타당하다.
추가 권장:
read loop 단일성 보장
stop/start 직렬화
processAudioData 종료 시 isRecording 정리
F1/F3/F2 stress test 유지
리스크 G: Triple pool buffer ownership 버그
분류: e75f9c66 이후 voice_logging 경로에서 영향이 커진 직접 버그
관련 파일:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.ktapp/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.ktapp/src/main/java/com/skmagic/ondeviceai/agent/service/SpeechRecognitionService.kt
1. 현상
오디오 로깅 데이터가 꼬이거나, callback buffer가 반환 누락/중복 반환될 수 있다.
2. 원인
기존 구현은 Triple<ShortArray, Int, Long>을 pool로 재사용하려 했다.
하지만 Kotlin Triple은 immutable이라 pool에서 꺼낸 객체에 새 buffer, size, timestamp를 갱신할 수 없다.
3. 분석
가능한 문제:
이전 buffer를 다시 처리
현재 buffer 반환 누락
stale audio가 pre/post WUW에 섞임
buffer pool이 점진적으로 고갈
음성로깅 이후 callback buffer 사용량이 늘면서 이 문제의 영향이 커졌다.
현재 수정 방향은 아래처럼 정리되어 있다.
Triple pool 제거
ForegroundService가 받은 ShortArray를 finally에서 반환
SpeechRecognitionService도 finally에서 반환
AudioRecordManager는 callback 예외 시에만 회수
4. 대처방안
현재 수정 방향 유지.
추가 권장:
RecordingCallback contract 문서화
새 callback 구현체 추가 시 반드시 finally에서 returnBuffer()
buffer pool 크기와 반환 횟수 debug counter 추가 검토
리스크별 우선순위
| 우선순위 | 리스크 | 기준 구분 | 판단 |
|---|---|---|---|
| P0 | Never stop recording | 이후 추가, 현재 revert | tombstone과 직접 연결 가능성이 가장 큼 |
| P0 | Triple pool ownership | 이후 영향 큼, 현재 수정 방향 있음 | 음성로깅 데이터/버퍼 반환 직접 버그 |
| 잠재 리스크 | read loop 종료 후 isRecording=true | 이전부터 가능 | 일반 서비스 시나리오에서는 낮음. 수정하면 좋음 |
| 잠재 리스크 | requestAudioRecord owner 초기화 순서 | 이전부터 가능 | 단독 재현성 낮음. 비정상 재초기화 방어 차원에서 수정하면 좋음 |
| 잠재 리스크 | 언어 변경 finally START | 이전부터 가능 | 상태 callback이 보정할 수 있음. 이전 상태 기반 복구로 수정하면 좋음 |
| P2 | 초기화 실패 후 IDLE/START | 이전부터 가능 | 실패를 정상 상태처럼 보이게 함 |
| P2 | pre/post WUW 책임 증가 | 이후 추가 | 기존 상태 문제를 증폭 |
실제 시나리오 발생 가능성 보정
위 잠재 리스크 항목들은 "현재 서비스 환경에서 바로 문제를 만든 확정 버그"가 아니다.
기존 서비스 시나리오에서는 정상 제품 gate가 대부분 막고 있었고, 실제 이슈가 없었던 것도 자연스럽다.
따라서 이 항목들은 release blocker라기보다 "수정하면 좋은 방어 보강 항목"으로 보는 것이 맞다.
먼저 정상 사양 gate를 보면 아래와 같다.
| 방어 gate | 막는 시나리오 | 남는 빈틈 |
|---|---|---|
CHANGE_LANGUAGE는 IDLE/PRE_RECORDING에서만 허용 |
MAIN_RECORDING 중 언어 변경 재초기화 |
앱 상태는 IDLE인데 AudioRecord 내부 상태만 recording으로 남은 경우 |
F1/F2/F3 앞단의 VoiceRecognitionStatusStore 검사 |
하이나무 사용안함, 음소거, 홈락, 프라이버시 중 음성 진입 | 언어 변경 finally가 내부 status를 START로 잠깐 덮는 경우 |
F1/F2에서 AudioRecordManager.isRecording() 검사 |
read loop 중복 시작 | read loop는 죽었지만 isRecording=true만 남은 경우 |
정상 stopRecording() 경로 |
일반 F3/취소 시 AudioRecord stop 및 상태 정리 | AudioRecord read 에러, coroutine cancel, 재초기화 실패처럼 stop 경로 밖에서 빠지는 경우 |
그래서 아래 항목들은 "현재 필드에서 바로 터진 확정 원인"이 아니라, P0를 고친 뒤에도 stress/비정상 타이밍에서 STT 입력 불능을 만들 수 있는 잔여 방어 지점이다.
read loop 종료 후 isRecording=true
일반적인 F1/F2/F3 흐름에서는 stopRecording()이 먼저 호출되고, 정상 stop 경로에서 isRecording=false가 된다.
그래서 사용자가 보통처럼 누르는 시나리오만 보면 이 상태가 자주 생기지는 않는다.
문제가 되는 경우는 아래처럼 read loop가 정상 stop이 아니라 내부 에러로 먼저 빠지는 경우다.
AudioRecord.ERROR_INVALID_OPERATION
AudioRecord.ERROR_BAD_VALUE
AudioRecord.ERROR_DEAD_OBJECT 후 복구 실패
즉 이 항목은 "일반 조작에서 바로 재현되는 버그"라기보다 "AudioRecord 에러/취소/재초기화 타이밍이 겹쳤을 때 STT 입력 불능으로 이어질 수 있는 방어 부족"이다.
requestAudioRecord owner 초기화 순서
언어 변경은 현재 IDLE 또는 PRE_RECORDING 상태에서만 받도록 막혀 있다.
CHANGE_LANGUAGE 허용 상태:
STATE_IDLE
STATE_PRE_RECORDING
그래서 MAIN_RECORDING 중에는 보통 언어 변경이 들어오지 않는다.
다만 위 read loop 리스크처럼 논리 상태와 실제 AudioRecord 상태가 어긋나 있으면, 앱 상태는 IDLE/PRE_RECORDING인데 AudioRecordManager.isRecording()만 true로 남는 조합이 가능하다. 이때 requestAudioRecord()가 owner를 먼저 null로 내리는 순서가 재초기화 실패를 더 쉽게 만든다.
따라서 이 항목은 독립적으로 매번 터지는 원인이라기보다, isRecording 잔류나 초기화 실패와 결합될 때 커지는 2차 리스크다.
언어 변경 finally의 무조건 START 복구
하이나무 사용안함/음소거/홈락/프라이버시 상태는 VoiceRecognitionStatusStore의 gate로 F1/F2/F3 입력을 막는다.
그래서 정상적으로 기기 상태 callback이 다시 들어오면 이후 상태가 다시 맞춰질 수 있다.
문제가 되는 지점은 언어 변경 finally가 "언어 변경 직전 사용자가 실제로 음성 인식을 켜 둔 상태였는지"를 저장하지 않고 START로 복구한다는 점이다.
즉 이 항목도 "언어 변경만 하면 무조건 STT가 열린다"는 뜻은 아니다.
정확한 리스크는 아래다.
언어 변경 전: 하이나무 사용안함 또는 음소거 계열 STOP
언어 변경 중: 내부 상태 INIT_NOW/RE_INIT
언어 변경 finally: VoiceRecognitionStatusStore.setStatus(START)
외부 상태 재동기화 전: 앱 내부 gate가 잠깐 실제 상태와 다를 수 있음
기기 상태 callback이 빠르게 들어오면 체감 문제가 없을 수 있다. 반대로 callback 지연, mute 해제, 언어 변경, F1/F2 입력이 가까운 타이밍에 겹치면 STT 입력 불능 또는 상태 오판으로 보일 수 있다.
보정 후 우선순위 판단
| 리스크 | 일반 사용 시나리오 | stress/비정상 타이밍 | 최종 판단 |
|---|---|---|---|
| read loop 종료 후 isRecording=true | 낮음 | 중간 | 잠재 리스크. 수정하면 좋음 |
| requestAudioRecord owner 초기화 순서 | 낮음 | 중간 | 잠재 리스크. 수정하면 좋음 |
| 언어 변경 finally START | 낮음~중간 | 중간 | 잠재 리스크. 수정하면 좋음 |
| Never stop recording | 높음 | 높음 | revert 유지 |
| Triple pool ownership | 중간 | 높음 | 수정 유지 |
따라서 실제 릴리즈 판단은 다음처럼 잡는 것이 맞다.
P0: tombstone 또는 buffer ownership에 직접 연결된 것
잠재 리스크: 정상 시나리오에서는 gate가 막지만, 에러/재초기화/stress에서 방어를 보강하면 좋은 것
P2: 로그/상태 해석을 어렵게 만드는 것
소스 리뷰 결과
리뷰 기준은 "음성로깅 기능을 유지하면서 AudioRecord tombstone과 STT 입력 불능 가능성을 줄일 수 있는가"다.
Critical/P0: AudioRecord 하드웨어 stop 생략은 revert 유지
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
stopRecording()
현재 판단:
현재 HEAD는 audioRecord.stop()이 복구되어 있어 방향이 맞다.
이유:
AudioRecord.read loop와 Android AudioFlinger 내부 buffer는 stop/start 경계가 명확해야 한다.
Never stop recording 방향은 pre-F2 음성 확보 목적은 이해되지만,
논리 상태(isRecording)와 하드웨어 capture 상태를 분리시켜 tombstone 리스크를 키운다.
리뷰 코멘트:
Do not re-apply "Never stop recording" as-is.
pre-F2 확보가 필요하면 AudioRecord lifecycle을 유지한 채 별도 ring buffer로 해결해야 한다.
Critical/P0: callback buffer ownership은 현재 수정 방향 유지
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.kt
onAudioBufferReceived()
app/src/main/java/com/skmagic/ondeviceai/agent/service/SpeechRecognitionService.kt
onAudioBufferReceived()
현재 판단:
ForegroundService와 SpeechRecognitionService가 받은 ShortArray를 finally에서 반환하는 방향은 맞다.
Triple pool 재사용은 다시 넣으면 안 된다.
이유:
AudioRecordManager는 callback마다 ShortArray를 pool에서 빌려준다.
정상 전달 후에는 consumer가 반환해야 한다.
Triple<ShortArray, Int, Long>은 immutable이라 재사용 pool로 쓰면 새 buffer/size/timestamp가 갱신되지 않는다.
그 결과 이전 buffer 처리, 현재 buffer 반환 누락, 로깅 데이터 꼬임이 생길 수 있다.
리뷰 코멘트:
RecordingCallback contract를 "callback 수신자가 finally에서 returnBuffer() 한다"로 고정한다.
AudioRecordManager는 callback 호출 전에 넘긴 buffer를 정상 경로에서 회수하지 않는다.
예외 경로에서만 bufferCopy를 회수한다.
Potential: processAudioData() 종료 시 isRecording 정리 보강은 수정하면 좋음
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
processAudioData()
현재 판단:
정상 F3/stopRecording 경로에서는 isRecording=false가 먼저 내려가므로 일반 시나리오 문제 가능성은 낮다.
다만 read loop가 ERROR_INVALID_OPERATION/ERROR_BAD_VALUE로 break되는 경우에는 종료 상태 정리가 약하다.
리뷰 코멘트:
processAudioData()가 stopRecording()이 아닌 내부 에러로 끝났다면 isRecording=false 정리가 필요하다.
단, 오래된 recordingJob이 새 recordingJob의 상태를 내려버리지 않도록 job identity 또는 generation check를 같이 넣어야 한다.
권장 수정 방향:
startRecording() 때 recordingGeneration 증가
processAudioData(generation) 실행
finally에서 현재 generation과 같을 때만 isRecording=false
에러 종료 reason 로그 추가
Potential: requestAudioRecord() owner null 순서 정리는 수정하면 좋음
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/utils/AudioRecordManager.kt
requestAudioRecord()
현재 판단:
언어 변경은 IDLE/PRE_RECORDING에서만 허용되므로 일반 시나리오에서 바로 문제되지는 않는다.
하지만 requestAudioRecord() 시작 시 currentClientId를 먼저 null로 내리는 코드는 리뷰상 좋지 않다.
이유:
isRecording=true가 남은 비정상 상태에서 currentClientId를 null로 만들면
기존 owner와 같은 client인지 판단할 수 없어진다.
그 결과 같은 SERVICE_ID 재초기화도 "다른 owner가 사용 중"처럼 실패할 수 있다.
리뷰 코멘트:
owner를 먼저 지우지 말고 기존 owner를 기준으로 허용 여부를 판단한다.
AudioRecord 확보와 callback 등록이 성공한 뒤 currentClientId를 갱신한다.
권장 수정 방향:
val previousOwner = currentClientId
if (isRecording.get() && previousOwner != null && previousOwner != clientId) return false
callback 등록
AudioRecord 확보
성공 시 currentClientId = clientId
Potential: switchLanguage() finally START는 이전 상태/기기 상태 기반 복구로 수정하면 좋음
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.kt
CHANGE_LANGUAGE
switchLanguage()
app/src/main/java/com/skmagic/ondeviceai/agent/common/VoiceRecognitionStatusStore.kt
현재 판단:
하이나무 사용안함/음소거/홈락/프라이버시 gate가 있어 일반 시나리오에서 무조건 열리는 문제는 아니다.
다만 finally에서 내부 status를 START로 고정 복구하는 것은 상태 설계상 불안정하다.
리뷰 코멘트:
언어 변경은 임시 init 상태일 뿐, 사용자 음성인식 사용 여부를 바꾸는 액션이 아니다.
따라서 완료 후 START를 무조건 넣기보다 언어 변경 전 상태 또는 실제 기기 상태를 기준으로 복구해야 한다.
권장 수정 방향:
switchLanguage 진입 전 VoiceRecognitionStatusStore.status() 저장
언어 변경 완료 후 DeviceCommunicator.getSpeechRecognitionStatus() 또는 저장된 previous status 기준으로 복구
초기화 실패 시 IDLE/START 복구 금지
Minor/P2: upload 경로는 기능 분리되어 있으나 상태 로그 보강 권장
리뷰 대상:
app/src/main/java/com/skmagic/ondeviceai/agent/service/ForegroundService.kt
drainAndUploadPreF2Voice()
drainAndUploadPostWuwVoice()
uploadVoiceAsync()
app/src/main/java/com/skmagic/ondeviceai/agent/service/DeviceCommunicator.kt
uploadVoiceBlocking()
fetchS3UrlBlocking()
현재 판단:
S3 URL 요청과 PUT은 voiceUploadExecutor에서 수행되므로 AudioRecord callback을 직접 block하지 않는다.
현재 구조는 기능 분리 방향이 맞다.
리뷰 코멘트:
업로드 실패는 STT 실패로 전파하지 않는 현재 방향을 유지한다.
다만 field 분석용으로 drain sample count, WAV byte size, S3 URL timeout, upload success/failure를 한 turn/logId 기준으로 묶어 남기면 좋다.
권장 수정 방향:
logId + WUW_FILE_NAME/MAIN_RECORDING_FILE_NAME + sample count + upload result를 한 줄 summary로 남긴다.
AudioRecord read error와 upload failure를 같은 장애로 오인하지 않도록 로그 prefix를 분리한다.
테스트 관점
필수 테스트는 세 종류다.
1. 단위 테스트
dictionary/FunctionCallHandler/순수 로직 회귀 확인
2. 빌드 테스트
compileDebugKotlin 또는 assembleDebug
3. 실기기 stress
F1/F3 반복 중 랜덤 F2 주입
releaseBuffer/mUnreleased/SIGABRT/tombstone 확인
현재 코드 레벨에서 추가로 만들면 좋은 테스트:
AudioRecordManager read loop 종료 시 isRecording=false가 되는지
requestAudioRecord()가 currentClientId를 먼저 null로 날리지 않는지
언어 변경 완료 시 실제 기기 상태를 재조회하는지
RecordingCallback이 받은 buffer를 finally에서 반환하는지
최종 판단
질문: 이 문제는 음성로깅 부분에서 발생한 문제인가?
답:
음성로깅만의 문제는 아니다.
정확히는 다음이다.
1. e75f9c66 이전부터 AudioRecord 상태 관리의 약한 부분이 있었다.
2. e75f9c66 이후 voice_logging 변경이 AudioRecord를 더 오래, 더 복잡하게 쓰도록 만들었다.
3. 그 과정에서 Never stop recording과 Triple pool 같은 직접 위험이 추가됐다.
4. 현재는 Never stop recording은 revert됐고, Triple pool 제거 방향도 맞다.
5. 남은 핵심은 isRecording/read loop/requestAudioRecord/언어변경 상태 복구를 정리하는 것이다.