← Docs hub

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다.

CPU STT와 QNN 화자 companion 연동 구조

QNN 화자 companion 실기기 검증 Gate

CPU STT와 QNN 화자 사용자 시험 흐름

2. 문제가 무엇이었나

기존 목표는 한 발화에서 다음 두 일을 병렬로 수행하는 것이었다.

단순히 같은 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 초기화

  1. ForegroundServiceSpeakerQnnProcessClient를 생성한다.
  2. client가 explicit component로 SpeakerQnnIsolatedService에 bind한다.
  3. companion이 QNN 2.44 host runtime, v68 stub/skel, W8A16 serialized context를 복원한다.
  4. companion이 provider=qnn을 확인한다.
  5. main 등록 디렉터리에 있고 companion에 없는 화자를 WAV PFD로 동기화한다.
  6. 어느 단계든 실패하면 main은 CPU speaker engine을 초기화한다.

3.3 한 턴의 추론

  1. 하나의 immutable PCM snapshot을 만든다.
  2. TurnInferenceCoordinator가 CPU STT와 speaker task를 서로 다른 single-thread dispatcher에서 async로 시작한다.
  3. main은 float PCM을 임시 파일에 쓰고 read-only PFD를 companion에 전달한다.
  4. companion은 CPU segmentation/clustering과 QNN HTP embedding을 수행한다.
  5. 등록 reference가 있으면 CPU cosine raw match를 만든다.
  6. segment와 match를 Bundle로 main에 반환한다.
  7. coordinator는 두 branch가 success/failure terminal 결과에 도달할 때까지 모두 await한다.
  8. DominantSpeakerResolver가 전체 발화 점유율, 1/2위 격차, score를 검사한다.
  9. 승인된 경우에만 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 응답>을 사용한다.

SKLauncher 계열 화자 관리 화면

앱 내 한글 키보드의 실로굿 입력

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

의미: 등록 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를 진행한다.

  1. 화자인식 화면에서 화자등록을 누른다.
  2. 앱 내 키보드로 닉네임 하나를 입력한다.
  3. 평소 목소리로 충분히 말한 뒤 녹음을 정지하고 등록한다.
  4. 관리 화면을 닫고 Wake Word와 일반 질문을 말한다.
  5. CPU STT와 QNN speaker가 같은 PCM을 병렬 처리하고 hard join할 때까지 기다린다.
  6. 식별 성공 시 화면의 표시명(발음명)님, <기존 LLM 응답>과 음성의 발음명님, <기존 LLM 응답>을 확인한다.
  7. 첫 TTS가 끝난 뒤 Wake Word와 질문을 다시 말해 2회차 진입을 확인한다.

성공 시 다음 로그가 남는다.

SPEAKER_QNN_TEST_RESULT provider=qnn voiceId=<VOICE_ID> status=identified

이 로그는 진단용이다. 고정 QNN 화자 테스트 결과 TTS와 조기 resetState()는 제거됐고 일반 발화는 기존 LLM/TTS로 종결한다. unknown이면 화자 호칭만 생략하고 기존 응답은 유지한다.

8. 남은 안정성·정확도 Gate

  1. 사용자의 실제 목소리 등록과 표시명·한국어 TTS 발음명 확인
  2. 실제 Wake Word → 질문 → TTS를 연속 2회 수행
  3. 두 턴 모두 CPU STT + QNN speaker hard join 확인
  4. 10회 cold/warm 발화에서 LMKD/FastRPC/QNN 오류 없음
  5. CPU 직렬 기준과 병렬 구조의 P50/P95 측정
  6. 등록/미등록/다중화자 corpus의 FAR/FRR/EER 측정
  7. CMA/PSS/RSS high-water mark 기록

9. 코드 근거

Connections

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