← Docs hub

DeviceAgent ↔ 등록 App 구현 요청서

2026-09-29 구현 범위: 화자 등록·상태 전달·목록·삭제·서버 관심사 조회가 대상이다. 대화내역 저장·조회·삭제·용량 조회·실시간 저장 알림은 다음 차수로 제외했으며 현재 공개 인터페이스에 없다. 발음 보조 값도 외부에서 받거나 보내지 않고 AI-Agent 내부에서만 처리한다.

대상: SoC DeviceAgent 담당과 기기 등록 App 담당. 여기서 등록 App은 핸드폰 APP이나 Launcher가 아니라 기기의 화자인식 등록 화면이다. 외부에서 요청이 기기로 오는 방법은 SoC·APP·Backend 담당 간 설계 범위다.

담당별 범위 · AI-Agent 경계

상세 연동 보강: 8~13절에 Intent·제어 필드의 필수 여부, 메서드별 실행 가능 상태, 전체 callback 예시, 오류 코드와 인수 시험을 추가했다. 현재 코드가 자동 처리하지 않는 경우도 구분한다.

1. 구현할 연결

방향 수단 책임
DeviceAgent → 등록 App 명시적 Activity Intent 전달받은 등록 정보로 화면 열기
등록 App → DeviceAgent DeviceControlProxy / sendModuleCommand 버튼별 제어 요청 전달
DeviceAgent → 등록 App onModuleCallback(Bundle) AI-Agent가 만든 등록 상태 전달

DeviceAgent는 UI 요청을 AI-Agent에 중계한다. 등록 App은 직접 녹음하거나 임베딩 파일을 AI-Agent에 보내지 않는다. 마이크·음질 검사·등록 상태의 주체는 AI-Agent다.

2. DeviceAgent가 열 화면

항목 정확한 값
package com.sk.airbot.voiceprofileregistration
class com.sk.airbot.voiceprofileregistration.VoiceProfileRegistrationActivity
action com.sk.airbot.voiceprofile.action.REGISTER
flags FLAG_ACTIVITY_NEW_TASK OR FLAG_ACTIVITY_CLEAR_TOP

NEW_TASK는 서비스에서 Activity를 열 때 적합한 Task를 찾거나 새로 만든다. CLEAR_TOP은 대상 Activity 위의 화면을 정리한다. 이 Flag가 녹음이나 등록을 취소하는 것은 아니다. 기존 Activity 재사용 시에는 onNewIntent로 요청을 받으며, 실제 재사용 여부는 launchMode 등에도 영향을 받는다.

Intent extra 타입 전달 내용
requestId String 시작·취소 대상 등록 ID
nickname String 시작할 사용자 표시 이름
locale String 한국어 ko-KR / 영어 en-US
personalizationEnabled Boolean 시작 시 UI 초기 선택
command String 취소 전달이면 cancel; 일반 시작에서는 생략

locale은 등록 메타데이터이며 기기의 언어/STT/TTS 설정 변경 명령이 아니다. 송신 규약은 ko-KR/en-US이지만 현재 수신 검증은 비어 있지 않은 길이 32자 이하 문자열 검사이며 두 값만 강제하는 구현은 아니다.

원격 시작 경로는 화면을 여는 단계다. 사용자가 시작을 누르면 아래 start Bundle을 보낸다. 화면이 열렸다고 녹음 시작·등록 완료로 보고하지 않는다. 현재 원격 취소는 Activity에 전달되므로 화면 실행 실패나 다른 활성 요청 충돌의 외부 결과 처리는 SoC 담당이 보완할 항목이다.

3. Proxy와 callback 등록

항목 값·동작
연결 DeviceControlProxy.connect(context, callback, true, callbackClassName)
callbackClassName com.sk.airbot.voiceprofileregistration.VoiceProfileRegistrationActivity
DeviceAgent 대상 번호 CALLER_VOICE_PROFILE_REGISTRATION=8
요청 sendModuleCommand(input:Bundle, output:Bundle):Int
수신 IDeviceControlProxyCallback.onModuleCallback(outdata:Bundle)
연결 후 getVoiceProfileEnrollmentState 요청

SoC는 등록 App의 callback 이름·대상 번호와 허용 호출자 패키지를 연결한다. 등록 App 담당은 Proxy 연결·해제와 화면 생명주기를 관리한다. 기기 설치 시 플랫폼 서명과 권한을 맞춰야 하며 임의 debug 서명으로 시스템 앱을 교체하지 않는다.

4. 공통 Bundle과 버튼 요청

모든 제어 요청은 아래 필드를 포함한다. output Bundle에서 최종 결과를 읽지 않는다.

필드 타입 값
method String 아래 메서드명
contractVersion String voice-profile-v2
requestId String Intent에서 받은 등록 ID
issuedAtMs Long 요청 생성 시각, System.currentTimeMillis()

현재 등록 App 클라이언트는 expiresAtMs를 보내지 않는다. 외부 통신의 만료 필드를 이 UI 요청에 임의로 필수화하지 않는다.

버튼/상황 method 추가 필드 기대 처리
연결·복귀 getVoiceProfileEnrollmentState 없음 현재 상태 수신. 세션 없음은 WAITING_START
시작 startVoiceProfileEnrollment nickname:String, locale:String, 기존 호환 userId:String RECORDING_SET 또는 거절 상태
다시 녹음 retryVoiceProfileSet setIndex:Int 현재 세트 재녹음. AI-Agent가 재시도 가능 상태 검사
나가기 확인창 pauseVoiceProfileEnrollmentForExit setIndex:Int 녹음 일시 정리. 별도 pause ACK 없음
나가기 확정 cancelVoiceProfileEnrollment reason:String 종료 상태 확인 후 화면 닫기
개인화 선택 setVoiceProfilePersonalization enabled:Boolean 프로필 생성 단계로 이동
생성 재시도 retryVoiceProfileCreation 없음 재시도 가능한 실패 단계에서 생성
전달 재시도 retryVoiceProfileDelivery 없음 기존 프로필을 다시 전달. 새 voiceId 생성 아님
완료 닫기 closeVoiceProfileRegistration 없음 화면 종료 처리. 프로필 삭제 아님

nickname은 trim 후 1~40자이며 경로 구분자·제어문자를 허용하지 않는다. userId는 계정 관리 신규 요구가 아니라 현재 클라이언트 호환 필드다. DeviceAgent에서 nickname을 임의 사용자 계정 ID로 변환하지 않는다.

개인화 버튼은 enabled를 보내며 초기 Intent의 personalizationEnabled와 키가 다르다. 관심사 항목 등록·변경은 핸드폰 APP/Backend 기능이며 이 화면의 구현 범위가 아니다.

요청 예시

val input = Bundle().apply {
    putString("method", "retryVoiceProfileSet")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", requestId)
    putLong("issuedAtMs", System.currentTimeMillis())
    putInt("setIndex", currentSetIndex)
}
val result = DeviceControlProxy.getService()?.sendModuleCommand(input, Bundle())
// result가 0이어도 재녹음 화면을 확정하지 않고 상태 callback을 확인한다.

이 코드는 필드 형식 예시다. 실제 구현에서는 Contract 상수와 연결 오류 처리를 사용한다.

5. 즉시 반환과 실제 상태

반환/상황 DeviceAgent의 의미 등록 App 처리
0 요청 중계 경로 수행 callback 대기. 녹음/완료를 뜻하지 않음
-1 등록 요청 형식·버전 등 검증 거절 요청 실패 표시
-2 허용되지 않은 호출자 패키지·UID·연결 설정 확인
연결 없음 / RemoteException 전송 실패 성공으로 화면 전환하지 않음

등록 클라이언트의 send는 현재 0 이상이면 true다. 이는 전송 수락 Boolean이며 업무 결과가 아니다. DeviceAgent의 화면 실행 실패 -3은 원격 UI 시작 경로의 반환으로, 위 UI 제어 결과와 구분한다.

상태 callback 필드

필드 타입 내용
method / contractVersion 각각 String voiceProfileStateChanged / voice-profile-v2
requestId String 현재 화면의 요청과 비교
stateSequence Long 상태 순번
state String 다음 표의 enum 이름
setIndex / setCount 각각 Int 1부터 시작하는 현재 세트/전체 세트
progress Int 0~100
reasonCode String 빈 값 또는 상태 사유
terminal Boolean 종료 여부
nickname / voiceId String AI-Agent 제공 메타데이터. voiceId는 생성 후 포함

등록 클라이언트는 현재 nickname/voiceId를 화면 상태 객체에 옮기지 않는다. DeviceAgent는 원본 Bundle의 필드를 임의로 제거하지 않는다. 화면 담당은 현재 requestId, 버전과 상태값을 확인하고 이전/중복 순번이 화면을 되돌리지 않도록 처리한다.

state UI 의미·가능한 동작
WAITING_START 등록 시작 대기
RECORDING_SET 녹음 진행과 진행률 표시
SET_ACCEPTED 해당 세트 녹음 성공
SET_REJECTED 녹음 오류와 다시 녹음 표시
PERSONALIZATION_SETTINGS 개인화 선택
CREATING_LOCAL_PROFILE 프로필 생성 진행
PENDING_DEVICE_DELIVERY 결과 전달 진행
PROFILE_FAILED 생성 실패와 재시도
DELIVERY_FAILED 전달 실패와 재시도
COMPLETED 등록 완료 표시
CANCELLED 취소 종료 처리

VALIDATING_SET은 현재 전달 enum이 아니다. 화면 문구와 실제 state를 혼동하여 새 enum을 추가하지 않는다. 오류 상태라고 모두 terminal=true인 것은 아니다.

6. 취소·재연결·완료 규칙

상황 현재 구현 기준 / 필요한 확인
같은 requestId로 시작 재전송 AI-Agent는 보존 중인 세션 상태를 반환. 새 녹음을 강제하지 않음
녹음·생성 중 취소 AI-Agent가 지연 작업을 무효화하고 종료 상태 생성. 늦은 생성 결과는 폐기
취소 대상 세션 없음/ID 불일치 요청 ID의 취소 종료 상태를 반환하며 다른 활성 세션을 취소하지 않음
이미 종료된 같은 세션 취소 기존 종료 상태 보존. 항상 CANCELLED로 바뀌는 것은 아님
취소 전송 수락 즉시 finish하지 않고 해당 요청의 종료 callback 확인
취소 callback 10초 무응답 등록 App은 실패 안내·재시도 가능 상태 유지. 취소 성공으로 닫지 않음
Binder 재연결 현재 상태 조회. 녹음 start를 무조건 다시 보내지 않음
COMPLETED 완료 화면과 닫기. 로컬 시험 ACK인지 운영 저장 완료인지 통합 시험에서 구분

상태 순번은 현재 세션 기준이며 재부팅 복구까지 보장하지 않는다. 재시작 후 진행 중 등록의 복원·정리 정책은 별도 통합 확인 대상이다.

7. 양쪽 구현 요청과 인수 시험

담당 구현 요청 완료 확인
DeviceAgent 정확한 Activity/Intent extras로 화면 실행 닉네임·발음·locale·requestId 유지
DeviceAgent 등록 App 제어 9종을 AI-Agent에 전달 버튼 요청이 해당 AI-Agent 분기로 도착
DeviceAgent voiceProfileStateChanged를 등록 App(8)에 전달 상태·타입·순번 유지, 별도 성공 상태 생성 없음
등록 App Proxy 연결 및 상태 조회 초기/복귀 화면이 현재 상태와 일치
등록 App 상태별 버튼·오류·재시도·취소 표시 전송 수락과 완료 화면을 구분
공동 단절·취소·중복 요청 시험 다른 세션 영향 없음, 오류 후 빠져나오기/재시도 가능

필수 시나리오: 시작 전 닫기, 녹음 중 나가기 취소/확정, 녹음 실패 후 다시 녹음, 생성 실패, 전달 실패, 종료 callback 유실, 완료 후 닫기. 외부 MQTT 없이 시험 호출자로 화면 실행과 callback 왕복을 먼저 확인할 수 있다.

8. 필수 필드와 누락값 처리

송신 규칙은 연동 구현에서 지켜야 할 입력이고, 현재 처리는 코드가 실제 검사하거나 사용하는 방식이다. 기본값이 있다고 잘못된 요청이 항상 정상 처리되는 것은 아니다.

화면 Intent

키 시작/취소 필수 여부 기본·제약 현재 처리
requestId:String 둘 다 필수 비어 있지 않은 같은 등록 ID 취소 ID가 현재 화면과 다르면 등록 App이 무시
nickname:String 시작 필수 trim 후 1~40자, 안전한 표시 이름 원격 DeviceAgent 검사는 non-empty/길이, AI-Agent start는 경로 구분자·제어 문자도 검사
locale:String 시작 필수 ko-KR 또는 en-US 명시 원격 요청 누락은 검사 거절. 코드 내부 Intent 생성 기본값 ko-KR과 구분
personalizationEnabled:Boolean 시작 시 전송 true/false 명시 DeviceAgent 누락 기본 true. UI 초기값이며 최종 선택은 enabled로 재전송
command:String 취소만 필수 cancel 등록 App이 취소 경로로 처리. 시작 시 넣지 않음

등록 App 자체의 직접 실행 경로에는 일부 기본값이 있지만 정상 연동에서 누락 입력을 대신 채우는 규약으로 사용하지 않는다. 이 Intent에는 PCM/WAV/임베딩을 넣지 않는다.

제어 Bundle 추가 필드

공통 method/contractVersion/requestId/issuedAtMs는 4절을 따른다.

method 추가 필드의 송신 규칙 현재 AI-Agent 사용 여부·누락 처리
startVoiceProfileEnrollment nickname 필수, locale 필수; userId는 호환 값 userId는 프로필 생성에 사용하지 않음. nickname 위반은 AI-Agent IPC 검사에서 무응답 거절 가능
retryVoiceProfileSet setIndex:Int, 현재 화면 세트 번호 현재 Service는 입력 setIndex를 읽어 선택하지 않고 자기 세션의 현재 세트를 재시작
pauseVoiceProfileEnrollmentForExit setIndex:Int, 현재 화면 세트 번호 현재 Service는 requestId와 RECORDING_SET 상태로 판단; 입력 setIndex는 사용하지 않음
cancelVoiceProfileEnrollment reason:String 명시 현재 Service는 입력 reason을 최종 사유로 복사하지 않고 USER_CANCELLED를 사용
setVoiceProfilePersonalization enabled:Boolean 필수 누락 시 true로 읽힘. 누락을 동의 처리 수단으로 쓰지 않음
getVoiceProfileEnrollmentState / retryVoiceProfileCreation / retryVoiceProfileDelivery / closeVoiceProfileRegistration 추가 입력 없음 각 세션·상태 조건에 따라 처리

reason의 현재 UI 송신값은 USER_EXIT_CONFIRMED, MOBILE_CANCELLED, UI_STATE_TIMEOUT이다. 요청 사유와 반환 reasonCode는 같은 필드도 같은 값도 아니다.

DeviceAgent의 등록 App 제어 검사는 공통 envelope와 호출자 중심이다. 모든 UI payload 필드를 DeviceAgent에서 검사해 오류 callback으로 돌려주는 구조가 아니다. 잘못된 입력은 전달 반환 0 이후에도 AI-Agent에서 무시될 수 있다.

9. 메서드별 실행 조건과 상태 결과

method AI-Agent 실행 조건 정상 결과 조건 불일치·특이 사항
getVoiceProfileEnrollmentState 유효 요청 같은 ID 세션의 현재 상태 다른 ID/없음이면 요청 ID로 WAITING_START, 순번 0 반환. 다른 활성 세션을 교체하지 않음
startVoiceProfileEnrollment 입력 유효, 다른 진행 세션 없음 RECORDING_SET; 마이크 확보 실패면 SET_REJECTED/AUDIO_BUSY 같은 ID는 기존 상태만 재전송. 다른 활성 ID 존재 시 PROFILE_FAILED/ANOTHER_SESSION_ACTIVE, terminal=true
retryVoiceProfileSet 같은 ID, terminal=false, RECORDING_SET 또는 SET_REJECTED 현재 세트 RECORDING_SET 또는 SET_REJECTED 조건 불일치면 별도 callback 없이 return
pauseVoiceProfileEnrollmentForExit 같은 ID, RECORDING_SET 타이머·현재 녹음 정리 새 PAUSED 상태와 pause ACK 없음. UI는 확인창 유지
cancelVoiceProfileEnrollment 같은 ID의 비종료 세션 CANCELLED/USER_CANCELLED, terminal=true 대상 없음/ID 불일치도 요청 ID로 종료 상태 반환. 이미 종료됐으면 기존 상태 재전송
setVoiceProfilePersonalization 같은 ID, PERSONALIZATION_SETTINGS CREATING_LOCAL_PROFILE 이후 생성 결과 조건 불일치면 무응답. enabled는 관심사 배열이 아님
retryVoiceProfileCreation 같은 ID, terminal=false, PERSONALIZATION_SETTINGS 또는 PROFILE_FAILED, 통과 녹음 수=setCount CREATING_LOCAL_PROFILE 이후 결과 녹음 수 부족/상태 불일치는 무응답
retryVoiceProfileDelivery 같은 ID의 pending 있음, terminal=false, DELIVERY_FAILED PENDING_DEVICE_DELIVERY 이후 결과 pending 없으면 재생성하지 않고 무응답
closeVoiceProfileRegistration 같은 ID의 세션 있음 세션 참조와 pending 정리 비종료이면 먼저 취소. 완료 프로필 삭제 없음. 별도 close ACK 없음

나가기 확인창에서 계속하기를 누르면 현재 RECORDING_SET에 retryVoiceProfileSet을 보낸다. 이는 중단 지점의 PCM 이어붙이기가 아니라 현재 세트를 다시 녹음하는 동작이다.

세트 녹음 성공 뒤에는 700ms 표시 지연 후 다음 세트 또는 PERSONALIZATION_SETTINGS로 이동한다. 이 지연 중 취소하면 다음 단계로 넘어가지 않도록 AI-Agent가 작업을 무효화한다.

10. 상태 callback과 UI 수신 순서

완전한 녹음 상태 예시

// AI-Agent가 생성한 Bundle을 DeviceAgent가 등록 App callback(8)에 복사 전달한다.
val recording = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enrollment-example-001")
    putLong("stateSequence", 1L)
    putString("state", "RECORDING_SET")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "")
    putBoolean("terminal", false)
    putString("nickname", "사용자A")
    // voiceId는 생성 전이므로 생략한다.
}

DeviceAgent는 이 상태를 새로 만들어 보내는 것이 아니라 AI-Agent로부터 받은 값을 보존한다. 예시는 필드 타입 확인용이다.

실제 수신 필터

  1. Client에서 method=voiceProfileStateChanged, 버전=voice-profile-v2 확인.
  2. requestId 존재와 알려진 enum 확인. 모르는 enum이면 무시.
  3. UI Reducer에서 현재 화면의 requestId와 일치하는지 확인.
  4. stateSequence가 마지막 수신보다 클 때만 적용. 같거나 작으면 무시.
  5. 현재 화면이 이미 terminal이면 이후 상태 무시.
  6. 메인 스레드에서 진행률·버튼·오류를 갱신하고 취소 대기 중 terminal이면 화면 종료.

callback의 nickname/voiceId는 현재 Client의 UI 상태 객체로 옮기지 않는다. 현재 화면의 이름은 실행 Intent를 사용한다. 따라서 완료 callback을 임의로 닉네임 변경 통로로 사용하지 않는다.

시작→녹음 상태 왕복

DeviceAgent: requestId를 포함한 Intent로 화면 실행
등록 App: Proxy 연결 → getVoiceProfileEnrollmentState
DeviceAgent: 같은 Bundle을 AI-Agent callback(1)에 전달
AI-Agent: WAITING_START 상태 송신
DeviceAgent: 상태를 등록 App callback(8)에 전달
사용자: 시작 버튼
등록 App: startVoiceProfileEnrollment
AI-Agent: RECORDING_SET → SET_ACCEPTED 또는 SET_REJECTED
DeviceAgent: 순번과 타입을 보존하여 등록 App에 전달

나가기 확정→종료 왕복

등록 App: 녹음 중이면 pause 요청 → 나가기 확인창
사용자: 나가기 확정
등록 App: 취소 대기 표식을 먼저 설정 → cancel 요청 전송
AI-Agent: 현재 녹음/지연 작업 정리 → CANCELLED, terminal=true
DeviceAgent: 동일 requestId의 상태 callback 전달
등록 App: 상태 필터 통과 → finishAndRemoveTask()

전송 후 대기 표식을 만들면 빠른 callback을 놓칠 수 있으므로 순서가 중요하다. 취소 대기 10초는 elapsedRealtime() 기준이며 issuedAtMs의 벽시계와 혼용하지 않는다.

11. 오류 코드와 UI 처리

reasonCode 발생 단계·의미 UI 처리/재시도
ANOTHER_SESSION_ACTIVE 다른 등록이 진행 중 terminal 실패. 같은 화면에서 생성 재시도로 해결되지 않음
INVALID_NICKNAME 이름 부적합 분기 입력 수정 필요. 앞선 IPC 검사에서 callback 없이 거절될 수도 있음
AUDIO_BUSY 마이크 확보 실패 SET_REJECTED. 마이크 상태 확인 후 다시 녹음
NO_SPEECH / TOO_SHORT / INSUFFICIENT_SPEECH 발화 없음·길이·유효 발화 부족 다시 녹음
TOO_QUIET / TOO_LOUD / TOO_NOISY / INVALID_AUDIO 음량·클리핑·잡음·샘플 문제 환경 조정 후 다시 녹음
INVALID_EMBEDDING / INCONSISTENT_SPEAKER 임베딩 유효성·세트 간 일관성 실패 통과 세트를 비우고 1세트부터 다시 녹음
EMBEDDING_CREATION_FAILED 일반 생성 실패 PROFILE_FAILED. 보존된 세트로 생성 재시도 가능
INVALID_PROFILE_PAYLOAD / DEVICE_OUTBOX_FAILED DeviceAgent 검증·전달 대기 저장 실패 DELIVERY_FAILED. 입력/저장 상태 확인 후 전달 재시도
WAITING_CLOUD_SYNC 기기 인수만 완료 DELIVERY_FAILED. 등록 완료 화면으로 표시하지 않음
DEVICE_DELIVERY_TIMEOUT 전달 결과 10초 무응답 전달 재시도 또는 취소
DEVICE_AGENT_UNAVAILABLE 전달 중 예외 감지 연결 확인 후 재시도. 모든 단절이 이 코드로 수렴하는 것은 아님
USER_CANCELLED 취소 종료 terminal callback 확인 후 닫기
LOCAL_TEST_ACK 개발용 완료 우회 시험 환경에서만 완료. 운영 서버 저장 증거 아님

현재 UI는 일부 코드만 전용 문구로 매핑하고 나머지는 공통 오류 안내를 쓴다. 코드마다 별도 화면이 구현되어 있다는 뜻이 아니다. MULTI_SPEAKER는 UI 문구 매핑에 있으나 현재 검사기의 실제 다중 화자 검출을 보장하는 코드로 해석하지 않는다.

PROFILE_FAILED라도 terminal=true인 시작 거절과 terminal=false인 생성 실패를 구분한다. 현재 UI는 stage 중심으로 재시도 버튼을 선택하는 부분이 있어, 모든 실패에서 버튼이 실제 실행으로 이어지는지 인수 시험이 필요하다.

12. 시간·중복·재연결 제한

항목 현재 값/처리 연동 주의
최초 시작 대기 5분 UI_STATE_TIMEOUT으로 취소 요청 후 종료 callback 대기
한 세트 녹음 5,200ms DeviceAgent 타이머가 아니라 AI-Agent 처리
진행 갱신 / 성공 표시 500ms / 700ms 모든 callback을 별도 완료 이벤트로 해석하지 않음
전달 결과 대기 10초 AI-Agent가 DELIVERY_FAILED를 생성
나가기 종료 대기 10초 등록 App이 무응답 안내. 취소 완료를 지어내지 않음
완료 화면 자동 닫기 60초 close 요청 접수 후 UI 종료. 프로필 삭제 아님
네이티브 생성 최대 시간 별도 강제 종료 시간 없음 10초 취소 대기는 모델 실행 강제 종료 시간이 아님

새 요청 ID는 별도 등록이다. 같은 requestId의 start는 세션이 보존되는 동안만 중복 시작을 막는다. close로 세션이 제거된 후까지 영구 중복 방지를 보장하지 않는다.

UI는 마지막 stateSequence를 기억한다. AI-Agent가 재시작되어 순번 0으로 돌아오면 이전 UI가 새 상태를 무시할 수 있다. 세션 복구·순번 재설정 정책은 현재 완성된 기능으로 간주하지 않는다. 단순 재연결에서 상태 조회를 한다는 사실과 재부팅 복구 보장은 다르다.

13. 인터페이스 인수 시험표

ID 입력/행동 기대 결과 담당 확인
UI-01 유효 Intent로 시작 화면 열기 requestId·닉네임·발음·locale 유지, 자동 녹음 아님 DeviceAgent/등록 App
UI-02 Proxy 연결 직후 상태 조회 1회, 해당 ID의 callback 적용 양쪽
UI-03 시작 요청에서 nickname 생략 잘못된 입력을 완료로 처리하지 않음. AI-Agent 무응답 거절 가능 확인 등록 App/Service
UI-04 RECORDING_SET에서 나가기→계속하기 pause 후 현재 세트 재녹음, resume PCM 아님 등록 App/Service
UI-05 SET_REJECTED에서 다시 녹음 현재 세트 재시작, 이전 통과 세트 처리 확인 등록 App/Service
UI-06 상태 순번 3 다음 2 또는 3 도착 화면 되돌림 없음 등록 App
UI-07 다른 requestId의 CANCELLED 도착 현재 등록 화면 종료 안 됨 등록 App
UI-08 취소 결과가 전송 반환보다 먼저 도착 종료 callback 누락 없이 닫힘 양쪽
UI-09 취소 callback 차단 10초 후 안내·재시도 가능, 성공 종료 아님 등록 App
UI-10 생성 중 취소 후 늦은 생성 완료 취소 화면 재진입/완료 없음 Service/등록 App
UI-11 COMPLETED 뒤 close 화면 종료, 등록 프로필 유지 양쪽
UI-12 Service 재시작 후 낮은 순번 현재 복구 한계를 재현하고 제품 복구 정책 결정 공동

위 표는 합격 판정 기준과 확인할 제한 사항이다. 문서 검토·샘플 실행만으로 실기기 시험 통과를 뜻하지 않는다.

14. REQUEST / RESPONSE 값 예시

예시는 실제 Binder로 주고받는 Android Bundle을 Kotlin으로 구성한 코드다. 각 코드 블록은 독립된 예시이며 import android.os.Bundle을 사용한다. request는 요청 Bundle, callback은 상대편에서 구성해 돌려주는 Bundle이다. JSON 직렬화나 request/callback이라는 포장 필드는 전송하지 않는다. 필드명을 확인하기 쉽게 문자열로 표시했으며 구현에서는 기존 Contract 상수를 사용한다. 식별자·닉네임·시각은 설명용 값이다.

값 종류 Bundle에 넣는 방법
method, contractVersion, requestId, voiceId 등 문자열 putString
success, terminal, enabled 등 참/거짓 putBoolean
setIndex, setCount, progress putInt
stateSequence, issuedAtMs, expiresAtMs, revision putLong
profiles putParcelableArrayList: 내부 항목 각각 Bundle
interestCodes putStringArrayList
referenceEmbedding putFloatArray

sendModuleCommand()의 동기 반환 0은 해당 호출의 수락/중계를 뜻한다. 아래 callback과 별개이며 최종 성공을 뜻하지 않는다. callback이 동기 반환보다 먼저 도착할 수도 있다. 허용되지 않은 caller나 잘못된 필드는 중계 단계에서 거절될 수 있으므로, 모든 요청에 반드시 callback이 온다고 가정하지 않는다.

이 절의 요청은 등록 App → DeviceAgent → AI-Agent, callback은 AI-Agent → DeviceAgent → 등록 App 순서다. 모든 등록 제어 결과의 method는 voiceProfileStateChanged이며, 버튼별 startResponse 같은 method를 새로 만들지 않는다. 아래 상태 순번은 예시이고 녹음 진행 callback 수에 따라 실제 값은 달라진다.

14.1 화면 연결 직후 상태 조회

// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "getVoiceProfileEnrollmentState")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("issuedAtMs", 1790000000000L)
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("stateSequence", 0L)
    putString("state", "WAITING_START")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "")
    putBoolean("terminal", false)
    putString("nickname", "")
}

해당 ID의 세션이 아직 없을 때의 응답이다. 이미 진행 중이면 현재 상태를 반환하며 녹음을 새로 시작하지 않는다.

14.2 등록 시작

// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "startVoiceProfileEnrollment")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("issuedAtMs", 1790000001000L)
    putString("nickname", "Niky")
    putString("locale", "ko-KR")
    putString("userId", "sample-user-001")
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("stateSequence", 1L)
    putString("state", "RECORDING_SET")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "")
    putBoolean("terminal", false)
    putString("nickname", "Niky")
}

RECORDING_SET 수신이 녹음 상태 진입 신호다. 요청 전송만으로 등록 성공 화면을 띄우지 않는다. 같은 requestId의 중복 시작은 현재 상태를 반환한다. userId는 현재 등록 App이 함께 보내는 기존 필드이므로 예시에 포함했지만, AI-Agent가 프로필 식별 키로 사용하지 않는다. 화자 식별 키는 생성된 voiceId다.

14.3 녹음 자원 사용 중 오류와 다시 녹음

다음은 시작 시 다른 작업이 녹음 자원을 사용 중인 경우다. 앞의 정상 시작과는 별도 실패 사례다.

// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "startVoiceProfileEnrollment")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-busy-001")
    putLong("issuedAtMs", 1790000001000L)
    putString("nickname", "Niky")
    putString("locale", "ko-KR")
    putString("userId", "sample-user-001")
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-busy-001")
    putLong("stateSequence", 1L)
    putString("state", "SET_REJECTED")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "AUDIO_BUSY")
    putBoolean("terminal", false)
    putString("nickname", "Niky")
}
// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "retryVoiceProfileSet")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-busy-001")
    putLong("issuedAtMs", 1790000008000L)
    putInt("setIndex", 1)
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-busy-001")
    putLong("stateSequence", 2L)
    putString("state", "RECORDING_SET")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "")
    putBoolean("terminal", false)
    putString("nickname", "Niky")
}

재시도 응답은 녹음 자원을 확보한 경우다. 계속 사용 중이면 다시 거절될 수 있다. Service는 전달된 setIndex로 임의 세트를 선택하지 않고 현재 세트를 다시 녹음한다.

14.4 나가기 확정: 취소 결과를 받은 뒤 종료

// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "cancelVoiceProfileEnrollment")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("issuedAtMs", 1790000010000L)
    putString("reason", "USER_EXIT_CONFIRMED")
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("stateSequence", 8L)
    putString("state", "CANCELLED")
    putInt("setIndex", 1)
    putInt("setCount", 5)
    putInt("progress", 0)
    putString("reasonCode", "USER_CANCELLED")
    putBoolean("terminal", true)
    putString("nickname", "Niky")
}

진행 중인 동일 세션 취소 사례다. UI는 현재 requestId 및 증가한 stateSequence를 확인하고 종료한다. 이미 종료된 세션은 기존 종료 상태를 반환할 수 있다. reason은 요청 사유이고 callback의 reasonCode와 값이 같을 필요는 없다.

14.5 개인화 선택과 프로필 생성

5개 세트가 통과하여 PERSONALIZATION_SETTINGS일 때 보내는 요청이다. 관심사 코드 저장 요청이 아니라 개인화 사용 여부 선택이다.

// REQUEST: 요청 송신 측에서 구성
val request = Bundle().apply {
    putString("method", "setVoiceProfilePersonalization")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("issuedAtMs", 1790000040000L)
    putBoolean("enabled", true)
}

// RESPONSE: 상대편에서 구성하여 별도 callback으로 전달
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("stateSequence", 30L)
    putString("state", "CREATING_LOCAL_PROFILE")
    putInt("setIndex", 5)
    putInt("setCount", 5)
    putInt("progress", 100)
    putString("reasonCode", "")
    putBoolean("terminal", false)
    putString("nickname", "Niky")
}

요청을 처리하면 프로필 생성을 시작한다. 이후 PENDING_DEVICE_DELIVERY를 거치며, 전달 완료 조건을 충족했을 때 수신하는 최종 상태의 예시는 다음과 같다. 생성·전달 실패는 각각 PROFILE_FAILED·DELIVERY_FAILED이며 완료로 취급하지 않는다.

// 비동기 상태 callback: AI-Agent에서 구성
val callback = Bundle().apply {
    putString("method", "voiceProfileStateChanged")
    putString("contractVersion", "voice-profile-v2")
    putString("requestId", "enroll-001")
    putLong("stateSequence", 32L)
    putString("state", "COMPLETED")
    putInt("setIndex", 5)
    putInt("setCount", 5)
    putInt("progress", 100)
    putString("reasonCode", "")
    putBoolean("terminal", true)
    putString("nickname", "Niky")
    putString("voiceId", "vp_demo_01")
}

COMPLETED는 전달 완료 조건을 충족한 뒤에만 보낸다. 로컬 시험 ACK로 완료된 경우에는 실제 서버 저장 증거가 아니다.

14.6 일시 정지와 닫기는 별도 성공 응답이 없음

REQUEST method 필드 예시 현재 동작 / RESPONSE
pauseVoiceProfileEnrollmentForExit requestId=enroll-001, setIndex=1, issuedAtMs:Long RECORDING_SET에서 현재 캡처 정지. PAUSED callback 없음
closeVoiceProfileRegistration requestId=enroll-001, issuedAtMs:Long 종료된 세션 정리. 새 close 성공 callback 없음. 등록 프로필 유지

두 요청에도 contractVersion=voice-profile-v2를 넣는다. 일시 정지 뒤 계속하기는 retryVoiceProfileSet으로 현재 세트를 처음부터 녹음한다. 진행 중 세션에 close하면 취소가 먼저 수행될 수 있으므로, 정상 완료 뒤 닫기와 중간 취소를 구분한다.

Binder에서 주고받는 위치

방향 송신 / 전달 지점 수신 지점
등록 App → DeviceAgent Proxy의 sendModuleCommand(input, output) DeviceAgent의 명령 분기
AI-Agent → DeviceAgent sendModuleCommand(input, output) DeviceAgent의 명령 분기
DeviceAgent → AI-Agent onModuleCallbackLoop(CALLER_LLM=1, Bundle(input)) AI-Agent의 onModuleCallback
DeviceAgent → 등록 App onModuleCallbackLoop(CALLER_VOICE_PROFILE_REGISTRATION=8, Bundle(state)) 등록 App의 onModuleCallback

DeviceAgent는 요청/결과 method에 맞는 수신자에게 전달한다. 등록 상태는 등록 App과 IoTAgent로 전달하지만, 개인화 조회 결과를 등록 App에 전달하지 않는다. 표의 onModuleCallbackLoop는 DeviceAgent 내부 함수이며 등록 App에서 호출하는 API가 아니다.

등록 App의 실제 전송 형태는 다음과 같다. request에는 아래 각 사례의 요청 Bundle을 넣는다.

// 등록 App: DeviceControlProxy가 연결된 상태에서 실행
val service = DeviceControlProxy.getService()
if (service != null) {
    val output = Bundle()
    val returnCode = service.sendModuleCommand(request, output)
    val acceptedForRouting = returnCode >= 0
    // acceptedForRouting은 최종 등록 성공이 아니다.
    // 최종 상태는 아래의 별도 onModuleCallback으로 받는다.
}

다음은 등록 App callback에서 실제 타입으로 필드를 읽는 부분이다. 기존 callback 구현 안에서 사용하며, 상태 화면 적용 시 현재 requestId와 stateSequence를 대조한다.

override fun onModuleCallback(outdata: Bundle?) {
    val state = outdata ?: return
    if (state.getString("method") != "voiceProfileStateChanged") return
    if (state.getString("contractVersion") != "voice-profile-v2") return
    val requestId = state.getString("requestId") ?: return
    val stage = state.getString("state") ?: return
    val sequence = state.getLong("stateSequence", -1L)
    val setIndex = state.getInt("setIndex", 1)
    val progress = state.getInt("progress", 0)
    val terminal = state.getBoolean("terminal", false)
    val reasonCode = state.getString("reasonCode").orEmpty()
    // 추출 이후 기존 상태 reducer에 전달한다.
}

위 수신 코드는 등록 상태용이다. 개인화 결과는 AI-Agent에서 getStringArrayList("interestCodes"), getLong("revision"), getBoolean("success") 등 해당 결과 계약의 필드로 읽는다. 생성 예시의 callback을 요청자가 자기 자신에게 보내는 구조가 아니다.

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