CPU STT + QNN 화자인식 병렬 통합 구현
2026-08-07 제품 동시 상주 업데이트: CPU STT + QNN speaker hard join과 별도 companion Binder/PFD는 각각 실기기에서 통과했다. 그러나 Genie HTP와 speaker QNN HTP를 동시에 상주시키면 현재 432MiB CMA가 소진돼 LMKD가 프로세스를 종료한다. 현재 기기는 companion을
disabled-user로 두고 CPU speaker fallback으로 정상 운영 중이다. 최신 판정, 실측, APK 해시와 복구 상태는 CPU STT + QNN 화자 Companion 연동 및 실기기 검증을 기준으로 한다.
1. 결론
기존 SenseVoice STT는 CPU 경로로 유지하고, 화자 분석 중 별도 화자 식별
embedding만 Qualcomm QNN HTP로 이동한다. 한 턴의 동일 PCM snapshot을 두
branch가 동시에 처리하며, STT와 speaker branch가 모두 success 또는 failure에
도달한 뒤에만 VOICE_ID 후속 로직으로 진행한다.
immutable PCM snapshot
|
+-- stt-cpu-inference --------------------+
| |
+-- speaker-qnn-inference |
CPU diarization |
-> QNN embedding |
-> CPU cosine/VOICE_ID |
v
hard join barrier
|
command / profile lookup
이 배치는 실기기에서 충돌했던 대형 STT QNN context를 제거하고, 기존 Genie
LLM과 동시에 상주할 QNN context를 11,171,584-byte 화자 embedding
model.so 하나로 제한한다. 제품 전역 QNN 라이브러리는 교체하지 않고,
QNN 2.44 host runtime과 모델은 release APK의 arm64 native lib로 묶는다.
FastRPC client는 기기의 공개 /vendor/lib64/libcdsprpc.so를 사용한다.
이 URL의 이전 문서는 QNN STT + CPU speaker 방향을 설명했다. 2026-08-06 실기기 메모리 분석 뒤 제품 방향을 CPU STT + QNN speaker embedding으로 전환했으며, 현재 문서는 그 결정을 반영한 최신 기준이다.
2. 문제와 분석
2.1 기존 QNN STT 방향의 한계
이전 실기기 검증에서 SenseVoice QNN context는 약 240MiB FastRPC 매핑을 요청했다. Genie가 이미 HTP/CMA를 사용하는 제품 동시 상주 조건에서는 context 생성이 실패했다. STT 단독 격리 실행은 가능했지만 실제 앱의 LLM 동시 상주 조건을 충족하지 못했다.
따라서 아래 두 조건을 분리해야 했다.
| 조건 | 결과 |
|---|---|
| STT QNN 단독 초기화 | 가능 |
| STT QNN + Genie 동시 초기화 | HTP/CMA 부족으로 실패 |
| 제품 결론 | STT는 원래 CPU 경로 유지 |
2.2 화자 파이프라인에서 NPU로 옮길 범위
화자 처리 전체가 하나의 ONNX 모델은 아니다.
- diarization segmentation과 clustering: CPU 유지
- speaker embedding ONNX: QNN HTP
- 등록 embedding과 cosine 비교, threshold,
VOICE_ID결정: CPU 유지
따라서 speaker-qnn-inference 이름은 branch 전체가 NPU라는 뜻이 아니라,
그 branch가 QNN embedding을 포함한다는 뜻이다.
2.3 Sherpa provider와 실행 경계
기존 generic speaker extractor에 provider="qnn"만 넣는 방식은 충분하지
않다. ONNX Runtime binary에 QNNExecutionProvider 문자열이 있다는 사실도
실제 HTP 실행 증거가 아니다. 최종 구현은 ORT QNN EP에 의존하지 않고 Sherpa
QNN native API를 speaker extractor 안에 연결했다.
현재 native build 계약:
provider="qnn"이고 model 경로가.so이면 nativeQnnBackend와QnnModel을 생성한다.- APK native lib의
libQnnHtp.so와 QAIRT 생성libembedding_qairt_w8a16_pc_no_bn.so를 절대 경로로 연다. [1,300,80]float fbank를 QNN graph에 넣고[1,192]float embedding을 기존 Sherpa manager에 반환한다.- native QNN 초기화 오류는 프로세스
_Exit가 아니라 JNIRuntimeException으로 전달한다. - Kotlin은 초기화 실패 시 기존
embedding.onnxCPU extractor를 명시적으로 선택한다.
실기기 계측에서 추가로 확인한 핵심 결함은 factory ordering이었다.
SpeakerEmbeddingExtractorImpl::Create()가 native 분기를 만나기 전에 .so를
ONNX metadata reader로 열어 Protobuf parsing failed를 발생시켰다. 현재는
provider=qnn + .so를 먼저 판별해 ONNX parsing을 생략하고 native WeSpeaker
pipeline으로 직접 보낸다.
SenseVoice STT는 같은 JNI 안에서 계속 ONNX Runtime CPU provider를 사용한다.
3. 구현 구조
3.1 STT CPU 유지
- QNN STT model/config/runtime 준비 코드 제거
- language code와 무관하게 기존 SenseVoice CPU recognizer 생성
- worker 이름을
stt-cpu-inference로 고정 - QNN STT 격리를 위해 생략했던 Genie 초기화 조건 제거
3.2 QNN speaker embedding
| 항목 | 값 |
|---|---|
| 모델 | APK lib/arm64-v8a/libembedding_qairt_w8a16_pc_no_bn.so |
| host runtime | APK arm64 native lib, QNN 2.44 |
| HTP backend | APK lib/arm64-v8a/libQnnHtp.so |
| FastRPC | 공개 /vendor/lib64/libcdsprpc.so |
| v68 skel | APK native lib + /vendor/lib/rfsa/adsp/libQnnHtpV68Skel.so |
| provider 우선순위 | qnn -> cpu |
| QNN input | 16kHz, 3초, 48,000 samples |
| CPU fallback | 기존 variable-length 처리 |
파일 누락, runtime load 실패, QNN session 생성 실패가 발생하면 CPU fallback을 사용한다. QNN 고정 입력은 짧으면 pad, 길면 truncate한다.
등록 음성도 앱 시작 시 active provider로 다시 embedding해야 reference와 query가 같은 embedding 공간을 사용한다.
3.3 병렬 실행과 hard join
TurnInferenceCoordinator는 독립 single-thread dispatcher에서 STT와 speaker
task를 async로 시작한다. 완료 순서와 관계없이 양쪽을 모두 await한 다음
하나의 TurnInferenceResult를 반환한다.
- speaker 실패는 STT 성공을 취소하지 않는다.
- 서비스/상위 turn 취소는 두 child에 전파한다.
- 어느 쪽이 먼저 끝나도 downstream은 먼저 시작하지 않는다.
- 향후 사용자 profile, 권한, 개인화 정보 조회는 join 뒤
CompletedVoiceTurn.voiceId를 입력으로 시작한다.
CPU STT --------------------------┐
├-> await both -> CompletedVoiceTurn
QNN speaker embedding + CPU ID ---┘
4. Qualcomm 양자화와 모델 선택
원본 embedding.onnx를 고정 [1,300,80] fbank 입력으로 export한 뒤
Qualcomm QAIRT 2.44 converter를 사용했다. 원본 graph의 BatchNormalization
56개는 HTP가 직접 받을 수 없어 등가 Mul + Add로 치환했고, per-channel
weight quantization을 위해 bias가 없던 Conv 55개에는 0 bias를 추가했다.
두 rewrite는 FP32 출력 기준으로 수치 동등성을 확인했다.
| 항목 | 최종 값 |
|---|---|
| 입력 음성 | 16kHz, 3초 |
| feature | 80-bin fbank, global mean normalization |
| weight | signed INT8, per-channel |
| activation | unsigned INT16 |
| bias | INT32 |
| QNN I/O | float layout 보존 |
| 출력 파일 | libembedding_qairt_w8a16_pc_no_bn.so |
| 파일 크기 | 11,171,584 bytes |
W8A8은 HTP 실행 자체는 됐지만 FP32 embedding과의 cosine이 약 0.085로
무너져 폐기했다. 최종 W8A16 후보의 9개 입력 비교 결과:
| 측정 | 값 |
|---|---|
| FP32 대비 self cosine 평균 | 0.956658 |
| self cosine 최소 / 최대 | 0.942880 / 0.964515 |
| pairwise cosine delta 평균 | 0.023386 |
| pairwise cosine delta p95 / 최대 | 0.059112 / 0.097212 |
QCS6490 v68에서 QNN 2.44 backend graph finalize와 9개 입력 execute가 모두
성공했다. HTP execute 자체는 약 20.2ms였으며, qnn-net-run 프로세스/RPC
대기를 포함한 관측 평균은 약 51ms였다. model.so compose/finalize는 약
13초가 걸리므로 앱에서는 context를 턴마다 만들지 않고 지속 보유한다.
이 수치는 양자화 전후 embedding 보존과 HTP 기능을 증명하지만 등록 화자
accuracy, FAR/FRR/EER 또는 최종 threshold 증거는 아니다. 동일 corpus의
target VOICE_ID와 impostor pair를 이용한 별도 검증이 필요하다.
5. PUI 화자 등록 화면과의 연결
화자 등록 화면은 이 추론 구조가 사용할 reference embedding을 만드는 producer다. 현재 ondevice 앱에 관리/등록 overlay와 녹음 전환 로직이 이미 있으며, SKMLauncher2/PUI에서 들어오는 보호된 진입점만 없다.
PUI / setLauncherScreen
-> protected ondevice entrypoint
-> 기존 등록 overlay
-> speaker profile/embedding 저장
-> 다음 일반 발화의 QNN speaker lane에서 검색
코드 근거, VitalSign 별도 패키지 구조, 권장 Receiver/Activity 경계와 완료 계약은 PUI Launcher 화자 등록 화면 연동 분석에서 확인한다.
6. 검증 상태
| 계층 | 상태 | 의미 |
|---|---|---|
| Kotlin unit test | 통과 | coordinator와 runtime 선택 계약 검증 |
| Qualcomm QAIRT 양자화 | 통과 | W8A16, INT8 per-channel weight model.so 생성 |
| FP32 numerical comparison | 통과 | 9개 입력 embedding 보존 proxy 확인 |
| QCS6490 v68 standalone HTP | 통과 | graph compose/finalize/execute 확인 |
| Sherpa native build | 통과 | matching AAR/JNI/ORT 생성 |
| 플랫폼 서명 release APK | 통과 | v126/1.19.37, 기존 v125와 인증서 일치 |
| APK native packaging | 통과 | QNN/JNI/model 포함, duplicate libcdsprpc.so 제외 |
| release instrumentation build | 통과 | platform-signed acceptance APK 생성 |
| 앱 QNN provider 초기화 | 통과 | Speaker embedding provider=qnn |
| 실제 WAV hard join | 통과 | CPU STT + QNN speaker 양쪽 완료 후 join |
| 안전 경계 | 통과 | reboot/reset/remount/system replacement 없음 |
실제 VOICE_ID 정확도 |
미완료 | FAR/FRR/EER corpus 필요 |
연결 기기 192.168.10.5:5555에서 확인한 marker:
Time: 18.216
OK (1 test)
PASS provider=qnn sttProvider=cpu text=. speaker=SILOGOOD
registration=41ms stt=409ms speakerInference=35ms hardJoin=410ms
profile=SILOGOOD/실로굿/Silogood
STT가 .을 반환했으므로 이는 CPU STT branch 완료와 병렬성 증거이며
WER/CER 품질 결과는 아니다. 제품 persistent v125 앱은
/system/priv-app/MaumAi에 그대로 두고, 같은 release code/native bundle을
가진 platform-signed non-debug qnnsmoke 패키지로 계측했다.
7. 닉네임 한 번 입력과 언어별 TTS
등록 UI는 닉네임 하나만 받지만 저장 profile은 역할을 분리한다.
| 필드 | 용도 | SILOGOOD 예 |
|---|---|---|
voiceId |
디렉터리·history·식별 key | SILOGOOD |
displayName |
화면/API 표시 | SILOGOOD |
spokenNameKo |
한국어 TTS 호칭 | 실로굿 |
spokenNameEn |
영어 TTS 호칭 | Silogood |
현재 선택된 TTS 언어에 맞는 spoken name만 음성 문장에 붙인다. 한국어와
영어 script를 한 엔진에 섞어 보내지 않는다. 예를 들어 한글 닉네임의 안전한
영어 발음을 자동 생성할 수 없으면 영어 음성 호칭만 생략하고 화면에는
닉네임을 그대로 표시한다. profile은
files/speaker_id/<VOICE_ID>/profile.json에 저장하며 기존 폴더도 시작 시
자동 migration한다.
8. 전달물
대형 모델과 QNN runtime은 git에서 분리해 다음 폴더에 보존한다.
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/
artifacts/cpu_stt_qnn_speaker_native_qcs6490_v68_20260807/
최종 폴더에는 다음을 포함한다.
- 플랫폼 서명 release APK
- matching Sherpa AAR/JNI/ORT
- QNN 2.44 host runtime
- HTP v68 skel
- quantized embedding model
- 플랫폼 서명 release instrumentation APK
- SHA-256 manifest
- 실기기 evidence
signing key 자체는 포함하지 않는다.
9. main 이식 계획
현재 구현 브랜치의 기준은 test_sherpa_module이며 main은 수정하지 않는다.
후속 이식은 다음 순서로 진행한다.
- main의 DB schema와 초기화 순서를 보존
- matching AAR/JNI/ORT와 QNN speaker runtime 계약 이식
- CPU STT와 owned PCM snapshot 경로 이식
TurnInferenceCoordinator와 hard join 결과 타입 이식- join 뒤
VOICE_IDprofile lookup 연결 - 플랫폼 서명 release로 cold/warm 반복 검증
- CPU 직렬 기준과 CPU-STT/QNN-speaker 병렬 기준의 P50/P95 비교
- 동일 speaker corpus로 FAR/FRR/EER 및 threshold 재보정