음성 녹음 종료 문제와 해결 구조
녹음을 빠르게 시작·종료하거나 대화를 취소할 때 발생할 수 있었던 문제를 찾고, 버퍼 소유권과 마이크 종료 순서를 명확하게 만드는 방식으로 보강했다.
결론: 취소와 서비스 종료를 반복해도 앱 PID가 유지됐고, 취소된 음성은 STT로 넘어가지 않았다.
mUnreleased out of range,releaseBuffer,SIGABRT도 다시 나타나지 않았다. 다만 정상 STT·LLM·TTS 실행 뒤 커지는 native 메모리는 이 종료 패치와 별개로 계속 추적해야 한다.
1. 이런 문제가 있었다
문제 1. 같은 음성 버퍼가 두 번 반납될 수 있었다
마이크에서 받은 버퍼를 매니저와 실제 처리 코드가 각각 반납하면, 같은 배열이 pool에 두 번 들어갈 수 있다. 아직 처리 중인 버퍼가 다른 녹음에 다시 사용될 위험이 있었다.
문제 2. 마이크가 읽는 중에 release될 수 있었다
AudioRecord.read()가 끝나기 전에 stop과 release가 겹치면 Android native 오디오가
사용 중인 버퍼를 잃을 수 있다. 과거에는 mUnreleased out of range와 SIGABRT가
관측됐다.
문제 3. 취소한 음성이 STT로 넘어갈 수 있었다
취소 요청이어도 target이 STATE_STT_RUNNING이면 STT decode가 시작될 수 있었다.
그 상태에서 다음 녹음이 시작되면 같은 Sherpa recognizer를 동시에 사용할 위험이 있었다.
실기기 반복 시험 중 이 경로에서 Sherpa native SIGSEGV가 한 번 관측됐다.
2. 원인은 세 가지였다
| 원인 | 문제가 되는 이유 |
|---|---|
| 버퍼 반납 주체가 명확하지 않음 | 같은 버퍼가 중복 반납되거나 처리 전에 재사용될 수 있음 |
| read loop와 release 순서가 보장되지 않음 | native AudioRecord가 사용 중인데 객체가 해제될 수 있음 |
| 취소와 정상 발화 종료를 같은 방식으로 처리 | 취소된 음성이 STT로 넘어가 다음 녹음과 겹칠 수 있음 |
3. 이런 방법으로 해결했다
해결 1. 버퍼는 소비자가 정확히 한 번만 반납
버퍼마다 ReturnOnce를 붙였다. 처리 완료, 처리 중 예외, coroutine 취소가 발생해도
같은 버퍼는 한 번만 pool로 돌아간다. ShortArrayPool도 같은 배열의 중복 등록을
identity 기준으로 거부한다.
해결 2. 마이크 종료 순서를 고정
종료 순서를 아래와 같이 고정했다.
새 read 차단
→ 녹음 job 취소
→ AudioRecord.stop()
→ read loop 종료 확인
→ AudioRecord.release()
AudioRecordReadLoopGate는 read loop가 동시에 두 개 실행되지 않도록 막는다. read가
바로 끝나지 않으면 사용 중인 객체를 즉시 release하지 않고 종료 뒤로 미룬다.
해결 3. 취소는 STT를 실행하지 않고 IDLE로 종료
IS_CANCEL=true이면 요청 target과 관계없이 STATE_IDLE로 바꾼다. 정상 F3 종료만
기존처럼 STT decode로 보낸다.
해결 4. 서비스를 내리기 전에 음성 작업부터 종료
서비스가 바인딩된 상태에서는 stopService()를 호출해도 onDestroy()가 바로 오지
않을 수 있다. 그래서 서비스 종료 helper가 먼저 STOP_PLAYING(cancel)을 전달해
마이크·STT·TTS를 정리한 다음 started service를 중지하도록 변경했다.
4. 변경 전과 변경 후
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 버퍼 반납 | 여러 경로에서 반납 가능 | ReturnOnce가 한 번만 반납 |
| 중복 pool 등록 | 같은 배열이 다시 들어갈 수 있음 | identity 검사로 중복 거부 |
| read loop | 빠른 재시작 시 중첩 가능 | gate로 한 개만 실행 |
| 마이크 종료 | read 종료와 release가 겹칠 수 있음 | stop 후 read 종료를 확인하고 release |
| 취소 | target에 따라 STT로 갈 수 있음 | 항상 IDLE, STT decode 없음 |
| 서비스 종료 | onDestroy()에 의존할 수 있음 |
음성 작업을 먼저 취소한 뒤 서비스 종료 |
5. 종료 신호는 이렇게 처리된다
| 입력 | 현재 동작 | 확인 상태 |
|---|---|---|
| 정상 F3 | 마이크 종료 → STT → IDLE | 실기기 확인 |
대화 취소 / LLM_END |
마이크·STT·TTS 취소 → IDLE | 실기기 확인 |
앱 stopService() |
STOP_PLAYING(cancel) 후 서비스 종료 |
실기기 확인 |
LLM_STOP |
STOP 상태만 저장 | 보강 필요 |
6. 테스트 결과
코드와 빌드
| 시험 | 결과 |
|---|---|
AudioRecordSafetyTest |
4개 통과 |
VoiceStopPolicyTest |
6개 통과 |
| 발화 리소스 회귀 테스트 | 4개 통과 |
assembleRelease --rerun-tasks |
통과 |
| release APK 서명 검증 | 설치 APK와 동일한 platform 인증서 확인 |
실기기 192.168.10.5:5555
| 시험 | 결과 |
|---|---|
직접 STOP_PLAYING(cancel=true) |
마이크 종료, STT 없이 IDLE 복귀 |
| STT target이 포함된 취소 | target을 IDLE로 바꾸고 실행 중 decode 취소 |
| 직접 취소 반복 | 20회 정상, start/stop 수 일치, STT 결과 0회 |
| 정상 F1 → F2 → F3 | 2회 IDLE 복귀 |
| 앱 service-stop helper 순서 | STOP_PLAYING(cancel) 처리 후 started service 종료 확인 |
| 프로세스와 서비스 | PID 3376 유지, foreground/startRequested 유지 |
| native 오류 검색 | mUnreleased, releaseBuffer, SIGABRT, fatal signal 없음 |
| Android crash buffer | 신규 crash 없음 |
첫 정상 STT 뒤 PSS는 1,236,213 KB였고, 이어서 취소만 10회 반복한 뒤에는
1,233,274 KB였다. 취소 반복 때문에 계속 증가하는 모습은 없었다.
반면 정상 STT·LLM·TTS를 추가 실행한 뒤에는 native heap과 PSS가 더 커졌다.
showmap에서는 큰 비중이 Scudo native allocator 영역에 남아 있었다. 이번 패치의
ReturnOnce, 작은 buffer pool, read-loop gate는 모두 제한된 크기이므로 수백 MB 증가를
설명하지 못한다. 이는 모델 arena 재사용 또는 기존 native 추론 자원 유지 가능성이 있는
별도 메모리 조사 항목이다. 따라서 이번 결과를 앱 전체의 장시간 메모리 누수 부재로
해석하면 안 된다.
이 결과는 마이크 종료 충돌 방지와 취소 정책을 확인한 것이다. 읽힌 PCM이 모두 같은 순서로 STT에 들어갔는지는 별도 계측 항목이며, 아래 공백이 남아 있다.
7. 추가로 확인한 STT 연속성 공백
현재 AudioRecord callback은 오디오 청크마다 별도 coroutine을 실행한다. 마이크 read loop는
이 처리 작업이 끝나기를 기다리지 않고 종료될 수 있다.
AudioRecord.read() 완료
→ 청크 처리 coroutine 대기
→ F3가 상태를 STT_RUNNING으로 변경
→ 대기 중이던 coroutine이 새 상태를 읽음
→ 상태 검사에서 청크를 버림
따라서 native crash 방어는 보강됐지만, 마지막 청크가 절대 잘리지 않는다고 아직 보장할 수는 없다. 여러 coroutine이 동시에 실행되므로 STT 전달 순서도 코드로 고정돼 있지 않다. F2에서 recognizer stream을 교체하는 순간에도 처리 대기 중인 이전 청크가 새 stream으로 들어갈 수 있다.
권장 보강은 청크마다 coroutine을 만들지 않고 단일 queue와 단일 worker를 사용하는 것이다.
| 종료 종류 | 권장 순서 |
|---|---|
| 정상 F3 | 새 read 중지 → queue drain → 마지막 청크까지 STT 전달 → decode |
| 취소 | 새 read 중지 → queue 폐기 → recognizer 취소 → IDLE |
| F2 경계 | 이전 queue 경계 확정 → recognizer reset → 이후 청크만 새 stream에 전달 |
검증 시에는 read sequence, enqueue sequence, acceptWaveform sequence, sample 수를 함께 기록해
read == accepted + 정책상 폐기가 성립하는지 확인해야 한다. 현재 단위 테스트 10개는 버퍼 중복
반납과 종료 정책을 검증하지만, 이 샘플 연속성까지 검증하지는 않는다.
8. 아직 남은 LLM_STOP
LLM_STOP(2)은 음성 인식 설정 OFF, 전체 음소거, 음성 볼륨 0, OTA 진입 때 올 수
있다. 현재 앱은 STOP 상태만 저장하고 진행 중인 마이크를 직접 닫지 않는다.
현재: LLM_STOP → STOP 상태 저장
권장: LLM_STOP → STOP_PLAYING(cancel) → 음성 작업 정리 → STOP 상태 유지
따라서 일반 F3와 대화 취소는 이번 시험 범위에서 정상으로 확인했지만,
녹음 중 LLM_STOP은 별도 보강과 실기기 반복 시험이 필요하다.