← Docs hub

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 후속 로직으로 진행한다.

CPU STT와 QNN 화자 식별 병렬 실행 구조

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 모델은 아니다.

따라서 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 계약:

  1. provider="qnn"이고 model 경로가 .so이면 native QnnBackendQnnModel을 생성한다.
  2. APK native lib의 libQnnHtp.so와 QAIRT 생성 libembedding_qairt_w8a16_pc_no_bn.so를 절대 경로로 연다.
  3. [1,300,80] float fbank를 QNN graph에 넣고 [1,192] float embedding을 기존 Sherpa manager에 반환한다.
  4. native QNN 초기화 오류는 프로세스 _Exit가 아니라 JNI RuntimeException으로 전달한다.
  5. Kotlin은 초기화 실패 시 기존 embedding.onnx CPU 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 유지

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를 반환한다.

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/

최종 폴더에는 다음을 포함한다.

signing key 자체는 포함하지 않는다.

9. main 이식 계획

현재 구현 브랜치의 기준은 test_sherpa_module이며 main은 수정하지 않는다. 후속 이식은 다음 순서로 진행한다.

  1. main의 DB schema와 초기화 순서를 보존
  2. matching AAR/JNI/ORT와 QNN speaker runtime 계약 이식
  3. CPU STT와 owned PCM snapshot 경로 이식
  4. TurnInferenceCoordinator와 hard join 결과 타입 이식
  5. join 뒤 VOICE_ID profile lookup 연결
  6. 플랫폼 서명 release로 cold/warm 반복 검증
  7. CPU 직렬 기준과 CPU-STT/QNN-speaker 병렬 기준의 P50/P95 비교
  8. 동일 speaker corpus로 FAR/FRR/EER 및 threshold 재보정

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