← Docs hub

음성 녹음 종료 문제와 해결 구조

녹음을 빠르게 시작·종료하거나 대화를 취소할 때 발생할 수 있었던 문제를 찾고, 버퍼 소유권과 마이크 종료 순서를 명확하게 만드는 방식으로 보강했다.

결론: 취소와 서비스 종료를 반복해도 앱 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 rangeSIGABRT가 관측됐다.

문제 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으로 들어갈 수 있다.

STT 연속성의 남은 공백과 권장 구조

권장 보강은 청크마다 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은 별도 보강과 실기기 반복 시험이 필요하다.

관련 문서

Keyboard shortcuts

⌘K / Ctrl+KOpen command palette
/Focus search
g hGo to home
g pGo to projects
g sGo to sessions
j / kNext / prev row (tables)
?Show this help
EscClose dialogs

Structured queries

Mix key:value filters with free text in the palette:

type:sessionOnly session pages
project:llm-wikiFilter by project name (substring)
model:claudeFilter by model name (substring)
date:>2026-03-01Sessions after a date
date:<2026-04-01Sessions before a date
tags:rustPages mentioning a tag/topic
sort:dateSort results by date (newest first)

Example: type:session project:llm-wiki date:>2026-04 sort:date