DeviceAgent ↔ 등록 App 구현 요청서
2026-09-29 구현 범위: 화자 등록·상태 전달·목록·삭제·서버 관심사 조회가 대상이다. 대화내역 저장·조회·삭제·용량 조회·실시간 저장 알림은 다음 차수로 제외했으며 현재 공개 인터페이스에 없다. 발음 보조 값도 외부에서 받거나 보내지 않고 AI-Agent 내부에서만 처리한다.
대상: SoC DeviceAgent 담당과 기기 등록 App 담당. 여기서 등록 App은 핸드폰 APP이나 Launcher가 아니라 기기의 화자인식 등록 화면이다. 외부에서 요청이 기기로 오는 방법은 SoC·APP·Backend 담당 간 설계 범위다.
상세 연동 보강: 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로부터 받은 값을 보존한다. 예시는 필드 타입 확인용이다.
실제 수신 필터
- Client에서 method=voiceProfileStateChanged, 버전=voice-profile-v2 확인.
- requestId 존재와 알려진 enum 확인. 모르는 enum이면 무시.
- UI Reducer에서 현재 화면의 requestId와 일치하는지 확인.
- stateSequence가 마지막 수신보다 클 때만 적용. 같거나 작으면 무시.
- 현재 화면이 이미 terminal이면 이후 상태 무시.
- 메인 스레드에서 진행률·버튼·오류를 갱신하고 취소 대기 중 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
- 소스 위치: SoC
DeviceAgent.java,ModuleCmdCallbackPolicy.java; 등록 AppDeviceAgentVoiceProfileClient.kt,VoiceProfileRegistrationActivity.kt,VoiceProfileExitWait.kt. - 기존 등록 화면 상세 자료: Sample 설명과 과거 보완 항목 포함. 겹치는 현재 구현 기준은 본 페이지를 먼저 본다.
- 전체 시나리오 참고: APP/서버 흐름은 참고이며 이 구현 요청서의 책임 범위를 넓히지 않는다.