CPU STT + QNN 화자 Companion 연동 및 실기기 검증
1. 현재 판정
QNN 화자 모델 자체, Sherpa QNN 경로, 별도 UID companion, Binder/AIDL,
ParcelFileDescriptor PCM 전달은 실제 QCS6490/HTP v68 기기에서 동작한다.
CPU STT와 QNN speaker branch를 동시에 시작하고 양쪽을 모두 기다리는 hard
join도 통과했다.
초기 graph .so 구현에서는 Genie HTP context와 speaker QNN HTP context의
동시 상주 시 FastRPC/CMA 압력으로 실패했다. 이후 화자 graph를
9,519,104-byte serialized context로 바꾼 최종 구성에서는 등록 화자 1명을
포함한 QNN 화자 초기화가 1.349초로 줄었고, Genie, CPU STT, TTS까지 같은
서비스 시작에서 모두 준비됐다.
현재 speaker_qnn_test 모드는 더 이상 Genie를 건너뛰거나 고정 시험 TTS로
턴을 끝내지 않는다. QNN 진단 로그만 추가하고 CPU STT → 기존 LLM → 기존
TTS 흐름을 유지한다.
실제 사용자 등록 시험에서 확인됐던 main-thread QNN bind ANR과 AudioRecord
소유권 복구 결함도 수정했다. 저장과 QNN enroll을 background로 옮긴 플랫폼
서명 release를 새 192.168.123.118:5555 보드에 배포해 6초 등록, QNN
enrollment, STT 복구, companion 종료까지 통과했다. data/system APK hash도
최종 전달본과 일치한다.
따라서 현재 환경은 사용자가 실제 목소리로 acoustic acceptance를 진행할 수 있는 상태다. 아직 사용자 발화로 확인하지 않은 항목은 실제 Wake Word 2회, 표시명/한국어 TTS 호칭, 미등록·복수 화자 confidence gate다.
2. 문제가 무엇이었나
기존 목표는 한 발화에서 다음 두 일을 병렬로 수행하는 것이었다.
- SenseVoice STT: CPU
- 다중 화자 분석과 등록 화자 식별: QNN embedding을 포함한 speaker branch
단순히 같은 APK 프로세스에 QNN 2.44를 넣으면 이미 상주한 Genie/QNN library와 native namespace가 충돌했다. companion을 분리해 native runtime 소유권을 나누자 QNN 초기화는 성공했지만, 처음에는 system UID/SELinux FastRPC domain과 서로 다른 UID의 private file 접근 문제가 차례로 드러났다.
이를 다음처럼 분리했다.
| 문제 | 해결 |
|---|---|
| Genie와 Sherpa QNN native namespace 충돌 | QNN speaker를 별도 APK/UID로 분리 |
| system app SELinux/FastRPC 제약 | companion을 unique UID platform_app으로 실행 |
| 서로 다른 UID가 private WAV/PCM path를 읽지 못함 | AIDL에 ParcelFileDescriptor 사용 |
| companion package visibility | main manifest에 명시적 package query 추가 |
| companion 실패가 STT를 막는 문제 | main의 CPU speaker fallback 유지 |
| branch 전환 뒤 DB version downgrade crash | derived API mapping table 재생성 |
graph .so의 큰 FastRPC/HTP mapping과 긴 cold init |
serialized QNN context 복원으로 전환 |
| 기기에 한국어 IME가 없음 | overlay 안에 두벌식 한/영 키보드와 고정 미리보기 추가 |
| 시험 TTS 뒤 다음 Wake Word가 막힘 | 조기 resetState()를 제거하고 기존 TTS 완료 lifecycle로 복귀 |
| 등록 저장 중 앱 멈춤과 ANR | WAV 저장·QNN bind/enroll을 background dispatcher로 이동 |
| 등록 실패 뒤 STT/Wake Word 복구 불가 | AudioRecord 활성 소유자를 요청 전에 지우지 않고 terminal 뒤 STT 소유권 복구 |
| Wake Word receiver의 minSdk 30 release lint 오류 | API 33 이상/이하 KeyEvent extra 추출 분기 |
3. 실제 연동 구조
3.1 프로세스와 패키지
| 역할 | 패키지 | 실행/서명 |
|---|---|---|
| 제품 메인 | com.skmagic.ondeviceai.agent |
/system/priv-app, system UID, platform-signed release |
| QNN companion | com.skmagic.ondeviceai.agent.speakerqnn |
unique UID, platform_app, platform-signed release |
두 패키지는 signature permission
com.skmagic.ondeviceai.agent.permission.SPEAKER_QNN으로만 연결한다.
3.2 초기화
ForegroundService가SpeakerQnnProcessClient를 생성한다.- client가 explicit component로
SpeakerQnnIsolatedService에 bind한다. - companion이 QNN 2.44 host runtime, v68 stub/skel, W8A16 serialized context를 복원한다.
- companion이
provider=qnn을 확인한다. - main 등록 디렉터리에 있고 companion에 없는 화자를 WAV PFD로 동기화한다.
- 어느 단계든 실패하면 main은 CPU speaker engine을 초기화한다.
3.3 한 턴의 추론
- 하나의 immutable PCM snapshot을 만든다.
TurnInferenceCoordinator가 CPU STT와 speaker task를 서로 다른 single-thread dispatcher에서async로 시작한다.- main은 float PCM을 임시 파일에 쓰고 read-only PFD를 companion에 전달한다.
- companion은 CPU segmentation/clustering과 QNN HTP embedding을 수행한다.
- 등록 reference가 있으면 CPU cosine raw match를 만든다.
- segment와 match를
Bundle로 main에 반환한다. - coordinator는 두 branch가 success/failure terminal 결과에 도달할 때까지
모두
await한다. DominantSpeakerResolver가 전체 발화 점유율, 1/2위 격차, score를 검사한다.- 승인된 경우에만
CompletedVoiceTurn.voiceId가 후속 사용자 정보 조회에 쓰인다.
4. “화자인식” 등록 UI 연결
사용자는 마이크에 화자인식이라고 말하고 관리 메뉴에서 화자등록을 누르면
된다. 즉 음성 명령이 곧바로 녹음을 시작하는 것이 아니라 관리 overlay를
여는 진입점이다.
STT finalResult
→ 공백 제거 후 "화자인식" 포함 검사
→ showSpeakerManageOverlay()
→ 화자등록
→ 닉네임 입력
→ 녹음 시작/정지
→ main WAV 저장
→ companion enroll(voiceId, wav PFD)
기기에는 AOSP 영문 LatinIME만 있으므로 overlay 자체가 두벌식 한/영
키보드를 제공한다. 닉네임 하나에서 표시용 이름과 언어별 TTS 호칭을 자동
생성한다. Silogood(실로굿)이면 화면/API에는
Silogood(실로굿)님, <기존 LLM 응답>, 한국어 TTS에는
실로굿님, <기존 LLM 응답>을 사용한다.


5. 테스트: 문제 → 방법 → 측정 → 해석
5.1 CPU STT + QNN speaker hard join
문제: 두 모델을 병렬로 시작해도 한쪽 결과만 먼저 소비하면 향후
VOICE_ID 기반 정보 조회가 흔들린다.
방법: 실제 기기 WAV를 동일 snapshot으로 CPU STT와 QNN speaker에 넣고 coordinator가 두 결과를 모두 기다리는지 계측했다.
측정:
PASS provider=qnn sttProvider=cpu text=. speaker=SILOGOOD
registration=41ms stt=409ms speakerInference=35ms hardJoin=410ms
profile=SILOGOOD/실로굿/Silogood
해석: hard join은 가장 느린 STT와 거의 같은 410ms에 끝났으므로 직렬 444ms가 아니라 병렬 max 경로로 동작했다.
의미: 후속 profile/권한/개인화 조회는 완료 순서와 무관하게 text와
VOICE_ID가 모두 terminal인 시점부터 시작할 수 있다. 다만 text=.은
WER/CER 품질 결과가 아니다.
5.2 companion Binder/PFD
문제: QNN 단독 native 시험만으로는 실제 별도 UID 서비스와 private PCM/WAV 전달이 된다고 볼 수 없다.
방법: platform-signed non-debug deviceSmoke target에서 companion에
bind하고 98,560-sample PCM을 PFD로 전달했다.
측정:
SpeakerQnnRemote: ready provider=qnn registered=0
SpeakerQnnCompanionTest:
PASS provider=qnn segments=1 registered=[] matches=0 samples=98560
Time: 24.046
OK (1 test)
해석: 이 수치는 초기 graph .so 경로의 baseline이다. ready 이후 PFD
전달과 1-segment 분석은 약 174ms였고, smoke package에 등록 화자가 없으므로
matches=0은 정상이다. 최종 serialized context 경로에서는 등록 1명 포함
cold ready가 1.349초로 단축됐다.
의미: 패키지/UID/AIDL/PFD/QNN 경계는 연결됐다. cold-start를 매 턴 반복하면 안 되므로 제품에서는 context 지속 보유가 필요하다.
5.3 최종 전체 초기화와 UI
문제: native smoke 통과만으로는 system app UI, 등록 화자 동기화, Genie/CPU STT/TTS 동시 준비, 한글 입력까지 연결됐다고 볼 수 없다.
방법: serialized QNN context를 포함한 platform-signed deviceRelease를
재부팅 없이 설치하고 /system/priv-app/MaumAi/MaumAi.apk에도 같은 파일을
반영했다. speaker_qnn_test는 QNN 준비 실패를 숨기지 않되 Genie와 기존
LLM/TTS 흐름을 그대로 초기화한다. 보호된 service action으로 SKLauncher
계열 overlay를 열고 앱 내 키보드로 실로굿을 직접 조합했다.
측정:
| 구간 | 실기기 측정 |
|---|---|
| QNN 화자 cold init, 등록 1명 | 1.349초 |
| Genie LLM init/warm-up | 5.053초 |
| CPU STT init | 6.020초 |
| TTS init | 5.150초 |
| main / companion PID | 21960 / 15590 |
| overlay 입력 | EditText 실로굿, 키보드 미리보기 실로굿 |
| reboot/reset | 없음 |
Speaker runtime mode=speaker_qnn_test
SpeakerQnnRemote: ready provider=qnn registered=1 total=1349ms
Speaker registration done in process=:speaker_qnn: provider=qnn entries=1 took=1391ms
SPEAKER_QNN_TEST_MODE_ACTIVE: QNN speaker + product LLM flow
LLM initialization and warm-up took: 5053 ms
STT provider=cpu languageCode=15
STT model initialization took: 6020 ms
TTS initialization took: 5150 ms
Initialization complete. Ready to use!
SPEAKER_QNN_TEST_READY provider=qnn
해석: system/priv-app release 앱, 별도 UID companion, QNN HTP, Genie HTP, CPU STT, TTS, overlay가 하나의 전체 흐름으로 연결됐다. CPU STT 초기화는 이번 6.020초이며 이전 5.743초·5.936초·6.129초 관측과 함께 보면 이 기기의 통상 범위는 약 6초다.
의미: 기존 speaker_id/silogood WAV 5개가 companion에 동기화됐고
사용자 발화의 SILOGOOD 식별도 확인했다. FAR/FRR/EER은 별도 labeled
target/impostor corpus 없이는 확정할 수 없다.
5.4 초기 CMA 실패와 serialized context 해결
문제: companion 단독 성공 뒤 실제 제품 LLM을 함께 띄웠을 때도 살아야 한다.
방법: 초기 graph .so 경로와 최종 serialized context 경로에서
companion QNN context를 유지한 채 main Genie 초기화를 비교했다.
측정:
| 항목 | 관측 |
|---|---|
| RAM | 7,464,764 kB |
| ZRAM | 4,194,300 kB |
| CMA total | 442,368 kB (432 MiB) |
| 충돌 시 CMA free | 사실상 0 |
| companion PSS | 약 267 MiB |
| main RSS | 약 1.25~1.95 GiB |
초기 graph .so 결과 |
LMKD process kill |
| serialized context 크기 | 9,519,104 bytes |
| 최종 결과 | speaker QNN + Genie + CPU STT + TTS ready |
Genie mmap-budget=25 설정은 main host peak RSS를 약 351MiB까지 낮췄지만
동시 HTP context의 CMA 부족은 해결하지 못했다.
해석: 초기 실패는 CPU STT 초기화 전에 발생했으므로 CPU STT가 원인이 아니었다. 모델 양자화나 Binder 계약도 원인이 아니었다. speaker graph loading/mapping 방식을 serialized context 복원으로 바꾸자 같은 보드에서 두 HTP context가 초기화됐다.
의미: CMA를 먼저 증설해야만 시험할 수 있다는 이전 차단 조건은 해소됐다. 다만 10회 이상 반복 부하에서 LMKD/FastRPC와 메모리 high-water mark를 수집하는 안정성 gate는 여전히 남는다.
5.5 화자 등록 저장 ANR
문제: 실제 등록 overlay에서 녹음을 정지하면 WAV는 생성됐지만 화면이 멈추고 잠시 뒤 앱이 ANR 상태가 됐다. 이후 Wake Word를 다시 말해도 STT가 시작되지 않았다.
방법: main PID 19709의 등록 시작부터 ANR까지 logcat과
SpeakerQnnProcessClient, AudioRecordManager의 소유권 전환 경로를 함께
추적했다.
측정:
17:25:00 Speaker enrollment started
17:25:16 Saved speaker enrollment WAV: .../<voiceId>/<voiceId>_1.wav
17:25:33 HTP_HANDOFF owner=speaker_qnn ready=false
error=speaker QNN service connection timed out
17:25:33 AudioRecord already in use by null
17:25:33 SpeechRecognitionService : Failed to start speech recognition
17:25:37 ANR in com.skmagic.ondeviceai.agent
해석: 등록 정지 callback이 main thread에서
connectAndInitialize() -> CountDownLatch.await(15 seconds)를 실행했다.
그러나 ServiceConnection.onServiceConnected()도 main thread에서
전달되므로 callback이 latch를 풀지 못하는 자기교착 구조였다. timeout 뒤
main thread가 풀릴 때에는 AudioRecord owner가 미리 null로 지워져 STT
복구도 실패했다.
수정: WAV 저장과 QNN bind/enroll을 Dispatchers.Default에서 수행하고,
terminal 뒤 main thread에서 STT recorder와
VoiceRecognitionStatus.START를 복구한다. 저장 중 버튼 연타를 막고,
AudioRecord는 중지된 동일 소유자가 unregister될 때만 owner를 비운다.
현재 증거:
:app:testDeviceReleaseUnitTest PASS
:app:lintDeviceRelease PASS
:app:assembleDeviceRelease PASS
platform certificate preflight PASS
final APK SHA-256
7017b9e40122ce51a8cb09dfd79b83ed1d9950c91f307479610d7b307aa05edb
새 기기 실측:
Speaker enrollment started
Saved speaker enrollment WAV
SpeakerQnnRemote: ready provider=qnn total=590ms
Speaker registered in process=:speaker_qnn: codexsmoke
Speaker enrollment audio restored=true
Speaker enrollment finalized: registered=true
SpeakerQnnRemote: process-exit reason=client-unbound
SpeechRecognitionService : Speech recognition started
- 녹음: 6초
- WAV 저장부터 최종 등록까지: 약 2.998초
- QNN release: 115 ms
- main PID: 시험 전후
4816 - ANR, Java/native crash, AudioRecord owner 오류: 없음
의미: 등록 ANR 수정과 STT/Wake Word 상태 복구는 새 기기에서 통과했다.
임시 codexsmoke profile과 companion reference는 시험 뒤 삭제했고, 현재는
등록 화자 0명인 최초 사용자 시험 상태다.
5.6 새 기기 clean 배포와 기존 LLM/TTS handoff
문제: 기존 패키지와 화자 데이터가 없는 새 보드에서도 플랫폼 서명, QNN context, CPU STT, Genie, TTS를 다시 구성할 수 있어야 한다.
방법: Android 13 QCS6490 보드에 플랫폼 서명 release main/companion을
배포하고 speaker_qnn_test를 설정했다. main은 data update와
/system/priv-app/MaumAi/MaumAi.apk에 같은 APK를 배치했다. QNN clean
초기화 뒤 보호된 로컬 LLM 시험 action으로 STT release → Genie → LLM →
Genie release → STT restore → TTS를 실행했다.
측정:
| 항목 | 새 기기 결과 |
|---|---|
| clean QNN ready, 등록 0명 | 627 ms |
| QNN release | 92 ms |
| CPU STT 초기화 | 5.764초 |
| TTS 초기화 | 5.311초 |
| CPU STT release | 113 ms |
| Genie HTP ready | 5.853초 |
| LLM response | 2.226초 |
| Genie release | 222 ms |
| CPU STT restore | 6.950초 |
| TTS playback / final state | complete / STATE_IDLE |
해석: 새 보드에서도 serialized QNN context와 패키지 설정이 재현됐고,
companion은 client unbind 뒤 종료됐다. main PID 4816은 전체 시험 동안
유지됐다.
의미: 사용자가 등록을 시작할 환경은 준비됐다. 다만 CMA total 432 MiB에서 free가 0에 가까워졌고 LMKD가 낮은 우선순위 프로세스를 정리한 기록이 있어, QNN speaker 분석 뒤 release하는 sequential HTP lifecycle을 유지하고 10회 반복 안정성을 후속 측정해야 한다.
6. 현재 기기와 전달물
대상: 192.168.123.118:5555
| 항목 | 현재 값 |
|---|---|
| runtime mode | speaker_qnn_test |
| main 상태 | CPU STT + QNN speaker + Genie LLM + TTS |
| companion 상태 | platform release 설치, provider=qnn, unbind 시 process exit |
| main version | 126 / 1.19.37 |
| 최종 전달 main APK hash | 7017b9e4...aa05edb |
| main data/system APK hash | 모두 7017b9e4...aa05edb, PASS |
| companion APK hash | 1a14b182...a3428, PASS |
| serialized context hash | 5f609362...0dcd8b |
| signing certificate | c8a2e9bc...51192ab8 |
| 등록 화자 | 0명, 사용자 최초 등록 대기 |
| boot ID | 5c098ab0-e57c-4dd8-b752-0d9bb8fed762 |
| reboot/reset | 없음 |
| system APK mode | root:root, 0644, system_file |
| system mount | 개발용 overlay RW, remount-ro는 busy로 거절 |


사용자 시험 준비 로그:
SPEAKER_QNN_TEST_READY provider=qnn
Opening speaker management overlay for QNN test
외부 전달용 APK, AAR, QNN runtime, FP32/양자화 모델, 테스트 APK, Genie 설정 원본/조정본, SHA-256 manifest와 기기 evidence는 다음 폴더에 분리했다.
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/
artifacts/cpu_stt_qnn_speaker_companion_qcs6490_v68_20260807/
7. 사용자가 지금 시험하는 절차
최종 전달 APK의 data/system hash 일치와 등록 복구 gate가 통과했다. 기기에는 등록 화자 데이터가 없으므로 다음 절차로 acoustic acceptance를 진행한다.
화자인식화면에서화자등록을 누른다.- 앱 내 키보드로 닉네임 하나를 입력한다.
- 평소 목소리로 충분히 말한 뒤 녹음을 정지하고 등록한다.
- 관리 화면을 닫고 Wake Word와 일반 질문을 말한다.
- CPU STT와 QNN speaker가 같은 PCM을 병렬 처리하고 hard join할 때까지 기다린다.
- 식별 성공 시 화면의
표시명(발음명)님, <기존 LLM 응답>과 음성의발음명님, <기존 LLM 응답>을 확인한다. - 첫 TTS가 끝난 뒤 Wake Word와 질문을 다시 말해 2회차 진입을 확인한다.
성공 시 다음 로그가 남는다.
SPEAKER_QNN_TEST_RESULT provider=qnn voiceId=<VOICE_ID> status=identified
이 로그는 진단용이다. 고정 QNN 화자 테스트 결과 TTS와 조기
resetState()는 제거됐고 일반 발화는 기존 LLM/TTS로 종결한다. unknown이면
화자 호칭만 생략하고 기존 응답은 유지한다.
8. 남은 안정성·정확도 Gate
- 사용자의 실제 목소리 등록과 표시명·한국어 TTS 발음명 확인
- 실제 Wake Word → 질문 → TTS를 연속 2회 수행
- 두 턴 모두 CPU STT + QNN speaker hard join 확인
- 10회 cold/warm 발화에서 LMKD/FastRPC/QNN 오류 없음
- CPU 직렬 기준과 병렬 구조의 P50/P95 측정
- 등록/미등록/다중화자 corpus의 FAR/FRR/EER 측정
- CMA/PSS/RSS high-water mark 기록
9. 코드 근거
ForegroundService.kt: STT final result,화자인식명령, overlay, parallel turn, CPU fallback, QNN 사용자 시험 결과SpeakerRuntimeMode.kt: 전역 설정의product/speaker_qnn_test해석TurnInferenceCoordinator.kt: 두 branchasync와 hard joinSpeakerQnnProcessClient.kt: explicit bind, PFD PCM/WAV, 등록 동기화ISpeakerQnnService.aidl: initialize/analyze/enroll/release 계약SpeakerQnnIsolatedService.kt: QNN runtime ownership, diarization, raw matchDominantSpeakerResolver.kt: 점유율·격차·score 승인SpeakerVoiceProfile.kt: 입력 파싱, 표시명과 언어별 TTS 호칭NicknameKeyboardView.kt,HangulNicknameComposer.kt: 앱 내 한/영 입력ForegroundService.processVoiceCmd(): 호칭 + 기존 LLM/TTS와 Wake Word 복귀SqliteHelper.kt: branch downgrade 시 derived mapping table 재생성