[251017] feat: VoiceInputOverlay.v2.jsx and Optimization

🕐 커밋 시간: 2025. 10. 17. 21:16:50

📊 변경 통계:
  • 총 파일: 11개
  • 추가: +298줄
  • 삭제: -5389줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.backup.jsx

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx

🗑️ 삭제된 파일:
  - com.twin.app.shoptime/luna.md
  - com.twin.app.shoptime/shopperhounse_api.md
  - com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/BelowTablContainer.figma.jsx
  - com.twin.app.shoptime/vui-guide.2.md
  - com.twin.app.shoptime/vui-implement.md
  - com.twin.app.shoptime/vui-react.md
  - com.twin.app.shoptime/vui-test.1.md
  - com.twin.app.shoptime/vui.md
  - com.twin.app.shoptime/web-speech.md

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
     Added: SpotlightContainerDecorator(), clearTimerRef(), clearAllTimers()
     Deleted: renderModeContent()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.backup.jsx (javascript):
     Added: renderModeContent()
  📄 com.twin.app.shoptime/luna.md (md파일):
     Deleted: Layer(), Functions(), LS2Request(), PalmServiceBridge(), Bus(), function(), instance(), cancel(), deleteInstance(), dispatch(), createToast(), getSystemSettings(), onSuccess(), getConnectionStatus(), useEffect()
  📄 com.twin.app.shoptime/shopperhounse_api.md (md파일):
     Deleted: Success()
  📄 com.twin.app.shoptime/vui-implement.md (md파일):
     Deleted: dispatch(), Date(), useDispatch(), useSelector(), useEffect(), onSTTText(), SearchPanel(), useState(), useCallback(), setSearchQuery(), getSearch(), setIsVoiceOverlayVisible(), useSearchVoice(), setVoiceMode(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), setTimeout(), onClose(), stopPropagation(), Search(), App(), getLaunchParams(), clearLaunchParams(), pushPanel(), Input(), registerVoiceFramework(), performAction(), handleSTTText()
  📄 com.twin.app.shoptime/vui-react.md (md파일):
     Deleted: Interface(), Search(), Input(), function(), register(), App(), useDispatch(), useCallback(), getLaunchParams(), clearLaunchParams(), dispatch(), pushPanel(), useEffect(), SearchPanel(), getSearch(), onSuccess(), cancel(), LS2Request(), onCommand(), onFailure(), instance(), deleteInstance(), useRef(), onVoiceInput(), reportActionResult(), registerVoiceConductor(), setVoiceContext(), unregisterVoiceConductor(), setSearchQuery(), useVoiceConductor(), handleSearchSubmit(), setContext()
  📄 com.twin.app.shoptime/vui.md (md파일):
     Deleted: Interface(), Commands(), Controls(), Format()
  📄 com.twin.app.shoptime/web-speech.md (md파일):
     Deleted: Framework(), Hook(), constructor(), checkSupport(), initialize(), SpeechRecognition(), setupEventHandlers(), onStart(), onResult(), onError(), getErrorMessage(), onEnd(), start(), abort(), cleanup(), WebSpeechService(), dispatch(), Date(), useDispatch(), useSelector(), useEffect(), initializeWebSpeech(), onSTTText(), useCallback(), SearchPanel(), useState(), setSearchQuery(), setTimeout(), setIsVoiceOverlayVisible(), useWebSpeech(), setVoiceMode(), stopListening(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), onClose(), stopPropagation(), classNames(), renderModeContent(), async(), getUserMedia(), getTracks(), preventDefault(), startListening(), useSearchVoice()

🔧 주요 변경 내용:
  • 개발 문서 및 가이드 개선
  • API 서비스 레이어 개선
  • 테스트 커버리지 및 안정성 향상

BREAKING CHANGE: API 또는 설정 변경으로 인한 호환성 영향 가능
Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-10-17 21:16:54 +09:00
parent 6594858709
commit 31f051b061
11 changed files with 1200 additions and 5389 deletions

View File

@@ -1,439 +0,0 @@
# [251014] webOS Luna Service 호출 메커니즘 분석
## 1. 개요
이 프로젝트는 webOS TV 애플리케이션으로, **Luna Service**를 통해 webOS 시스템의 다양한 기능과 통신합니다. Luna Service는 webOS의 서비스 버스 아키텍처로, 애플리케이션이 시스템 서비스에 접근할 수 있게 해주는 IPC(Inter-Process Communication) 메커니즘입니다.
## 2. 아키텍처 구조
### 2.1 핵심 컴포넌트
```
src/lunaSend/
├── LS2Request.js # Enact LS2Request 래퍼
├── LS2RequestSingleton.js # 싱글톤 패턴 구현
├── index.js # 모듈 export 및 취소 함수
├── common.js # 공통 Luna Service 호출 함수들
├── account.js # 계정 관련 Luna Service 호출 함수들
└── lunaTest.js # 테스트용 파일
```
### 2.2 계층 구조
```
Application Layer (React Components/Actions)
Wrapper Functions (lunaSend/common.js, lunaSend/account.js)
LS2Request Layer (LS2Request.js)
@enact/webos/LS2Request (Enact Framework)
PalmServiceBridge (webOS Native Bridge)
Luna Service Bus (webOS System Services)
```
## 3. Luna Service 호출 메커니즘
### 3.1 LS2Request 래퍼 (`LS2Request.js`)
```javascript
import LS2Request from '@enact/webos/LS2Request';
let request = LS2Request;
export {request};
export default request;
```
- **역할**: Enact 프레임워크의 `@enact/webos/LS2Request` 모듈을 import하여 재export
- **목적**: 향후 mock 구현이나 개발 환경에서의 대체가 용이하도록 추상화 계층 제공
### 3.2 LS2RequestSingleton (`LS2RequestSingleton.js`)
```javascript
import LS2Request from './LS2Request';
const ls2instances = {};
export const LS2RequestSingleton = {
instance: function (skey) {
ls2instances[skey] = ls2instances[skey] || new LS2Request();
return ls2instances[skey];
},
deleteInstance: function (skey) {
ls2instances[skey] = null;
}
};
```
- **패턴**: Singleton Factory 패턴
- **기능**:
- 키별로 LS2Request 인스턴스를 관리
- 동일한 키에 대해 재사용 가능한 인스턴스 제공
- 인스턴스 삭제를 통한 메모리 관리
### 3.3 기본 호출 패턴
Luna Service 호출의 기본 구조는 다음과 같습니다:
```javascript
new LS2Request().send({
service: "luna://[service-name]",
method: "[method-name]",
parameters: { /* 파라미터 객체 */ },
subscribe: true/false, // 구독 여부
onSuccess: (response) => { /* 성공 콜백 */ },
onFailure: (error) => { /* 실패 콜백 */ },
onComplete: (response) => { /* 완료 콜백 */ }
});
```
### 3.4 환경 감지 및 Mock 처리
모든 Luna Service 호출 함수는 다음과 같은 환경 감지 로직을 포함합니다:
```javascript
if (typeof window === "object" && window.PalmSystem &&
process.env.REACT_APP_MODE !== "DEBUG") {
// 실제 webOS 환경에서 Luna Service 호출
return new LS2Request().send({ ... });
} else {
// 개발 환경에서 mock 데이터 반환
console.log("LUNA SEND [function-name]", ...);
return mockData;
}
```
- **`window.PalmSystem`**: webOS TV 환경에서만 존재하는 전역 객체
- **`process.env.REACT_APP_MODE !== "DEBUG"`**: DEBUG 모드가 아닐 때만 실제 호출
## 4. 호출되는 Luna Service 목록
### 4.1 시스템 정보 및 설정
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.tv.systemproperty` | `getSystemInfo` | 시스템 정보 조회 | account.js |
| `luna://com.webos.settingsservice` | `getSystemSettings` | 시스템 설정 조회 (자막 등) | common.js |
| `luna://com.webos.service.sm` | `deviceid/getIDs` | 디바이스 ID 조회 | account.js |
| `luna://com.webos.service.sdx` | `getHttpHeaderForServiceRequest` | HTTP 헤더 정보 조회 (구독) | common.js |
### 4.2 계정 관리
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.accountmanager` | `getLoginID` | 로그인 사용자 정보 조회 | account.js |
### 4.3 네트워크 연결
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.connectionmanager` | `getStatus` | 연결 상태 조회 (구독) | common.js |
| `luna://com.webos.service.connectionmanager` | `getinfo` | 연결 정보 조회 | common.js |
### 4.4 알림 (Notification)
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.notification` | `createToast` | 토스트 메시지 생성 | common.js |
| `luna://com.webos.notification` | `enable` | 알림 활성화 | common.js |
| `luna://com.webos.notification` | `disable` | 알림 비활성화 | common.js |
### 4.5 자막 (Subtitle)
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.tv.subtitle` | `enableSubtitle` | 자막 활성화 (3.0~4.5) | common.js |
| `luna://com.webos.service.tv.subtitle` | `disableSubtitle` | 자막 비활성화 (3.0~4.5) | common.js |
| `luna://com.webos.media` | `setSubtitleEnable` | 자막 설정 (5.0+) | common.js |
### 4.6 예약 (Reservation)
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.tvReservationAgent` | `insert` | 예약 추가 | common.js |
| `luna://com.webos.service.tvReservationAgent` | `delete` | 예약 삭제 | common.js |
### 4.7 데이터베이스 (DB8)
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.service.db` | `find` | 데이터 조회 | common.js |
| `luna://com.webos.service.db` | `put` | 데이터 저장 | common.js |
| `luna://com.webos.service.db` | `delKind` | Kind 삭제 | common.js |
| `luna://com.palm.db` | `search` | 데이터 검색 | common.js |
### 4.8 애플리케이션 실행
| Service URI | Method | 목적 | 파일 |
|------------|--------|------|------|
| `luna://com.webos.applicationManager` | `launch` | 멤버십 앱 실행 | account.js |
## 5. 주요 사용 예제
### 5.1 단순 호출 (One-time Request)
토스트 메시지 생성:
```javascript
export const createToast = (message) => {
if (typeof window === "object" && !window.PalmSystem) {
console.log("LUNA SEND createToast message", message);
return;
}
return new LS2Request().send({
service: "luna://com.webos.notification",
method: "createToast",
parameters: {
message: message,
iconUrl: "",
noaction: true,
},
onSuccess: (res) => {
console.log("LUNA SEND createToast success", message);
},
onFailure: (err) => {
console.log("LUNA SEND createToast failed", err);
},
});
};
```
### 5.2 구독 (Subscribe)
연결 상태 모니터링:
```javascript
export const getConnectionStatus = ({ onSuccess, onFailure, onComplete }) => {
if (typeof window === "object" && !window.PalmSystem) {
return "Some Hard Coded Mock Data";
} else {
return new LS2Request().send({
service: "luna://com.webos.service.connectionmanager",
method: "getStatus",
subscribe: true, // 구독 모드
parameters: {},
onSuccess,
onFailure,
onComplete,
});
}
};
```
**특징**:
- `subscribe: true` 설정 시 상태 변화 시마다 onSuccess 콜백 호출
- 반환된 핸들러를 통해 `.cancel()` 메서드로 구독 취소 가능
### 5.3 조건부 호출
자막 활성화/비활성화:
```javascript
export const setSubtitleEnable = (
mediaId,
captionEnable,
{ onSuccess, onFailure, onComplete }
) => {
if (typeof window === "object" && window.PalmSystem &&
process.env.REACT_APP_MODE !== "DEBUG") {
if (captionEnable) {
return new LS2Request().send({
service: "luna://com.webos.service.tv.subtitle",
method: "enableSubtitle",
parameters: { pipelineId: mediaId },
onSuccess, onFailure, onComplete,
});
} else {
return new LS2Request().send({
service: "luna://com.webos.service.tv.subtitle",
method: "disableSubtitle",
parameters: { pipelineId: mediaId },
onSuccess, onFailure, onComplete,
});
}
}
};
```
### 5.4 구독 취소
```javascript
export const cancelReq = (instanceName) => {
let r = LS2RequestSingleton.instance(instanceName);
if (r) {
r.cancel();
r.cancelled = false;
LS2RequestSingleton.deleteInstance(instanceName);
}
};
```
### 5.5 Redux Action에서 사용
```javascript
export const alertToast = (payload) => (dispatch, getState) => {
if (typeof window === "object" && !window.PalmSystem) {
dispatch(changeAppStatus({ toast: true, toastText: payload }));
} else {
lunaSend.createToast(payload);
}
};
export const getSystemSettings = () => (dispatch, getState) => {
lunaSend.getSystemSettings(
{ category: "caption", keys: ["captionEnable"] },
{
onSuccess: (res) => {},
onFailure: (err) => {},
onComplete: (res) => {
if (res && res.settings) {
if (typeof res.settings.captionEnable !== "undefined") {
dispatch(changeAppStatus({
captionEnable: res.settings.captionEnable === "on" ||
res.settings.captionEnable === true,
}));
}
}
},
}
);
};
```
## 6. 콜백 함수 패턴
Luna Service 호출은 3가지 콜백을 지원합니다:
### 6.1 onSuccess
- **호출 시점**: 서비스 호출이 성공했을 때
- **용도**: 성공 응답 데이터 처리
- **구독 모드**: 데이터가 업데이트될 때마다 호출됨
### 6.2 onFailure
- **호출 시점**: 서비스 호출이 실패했을 때
- **용도**: 에러 처리 및 로깅
### 6.3 onComplete
- **호출 시점**: 서비스 호출이 완료되었을 때 (성공/실패 무관)
- **용도**: 로딩 상태 해제, 리소스 정리 등
## 7. 개발 환경 지원
### 7.1 환경 감지
모든 Luna Service 호출 함수는 다음 조건을 확인합니다:
```javascript
typeof window === "object" && window.PalmSystem &&
process.env.REACT_APP_MODE !== "DEBUG"
```
- **webOS 실제 환경**: 모든 조건 충족 → 실제 Luna Service 호출
- **개발 환경**: 조건 불충족 → Mock 데이터 반환 또는 콘솔 로그
### 7.2 Mock 데이터 예시
```javascript
// getLoginUserData에서 개발 환경용 mock 데이터
const mockRes = {
HOST: "qt2-US.nextlgsdp.com",
"X-User-Number": "US2412306099093",
Authorization: "eyJ0eXAiOiJKV1QiLCJhbGci...",
// ... 기타 헤더 정보
};
onSuccess(mockRes);
```
## 8. 프로젝트 의존성
### 8.1 Enact Framework
```json
"@enact/webos": "^3.3.0"
```
- **역할**: webOS 플랫폼 API 접근을 위한 Enact 프레임워크의 webOS 모듈
- **제공**: LS2Request 클래스 및 webOS 관련 유틸리티
### 8.2 주요 특징
- React 기반 webOS TV 애플리케이션
- Redux를 통한 상태 관리
- Sandstone 테마 사용
## 9. 베스트 프랙티스
### 9.1 에러 처리
```javascript
lunaSend.getSystemSettings(parameters, {
onSuccess: (res) => {
// 성공 처리
},
onFailure: (err) => {
console.error("Luna Service Error:", err);
// 사용자에게 에러 메시지 표시
},
onComplete: (res) => {
// 로딩 상태 해제
}
});
```
### 9.2 구독 관리
```javascript
// 구독 시작
let handler = lunaSend.getConnectionStatus({
onSuccess: (res) => {
// 상태 업데이트 처리
}
});
// 컴포넌트 언마운트 시 구독 취소
useEffect(() => {
return () => {
if (handler) {
handler.cancel();
}
};
}, []);
```
### 9.3 싱글톤 사용
특정 서비스에 대해 중복 호출을 방지해야 하는 경우:
```javascript
let httpHeaderHandler = null;
export const getHttpHeaderForServiceRequest = ({ onSuccess }) => {
if (httpHeaderHandler) {
httpHeaderHandler.cancel(); // 기존 요청 취소
}
httpHeaderHandler = new LS2Request().send({
service: "luna://com.webos.service.sdx",
method: "getHttpHeaderForServiceRequest",
subscribe: true,
parameters: {},
onSuccess,
});
return httpHeaderHandler;
};
```
## 10. 요약
이 프로젝트의 Luna Service 호출 메커니즘은 다음과 같은 특징을 가집니다:
1. **계층화된 아키텍처**: Enact LS2Request → 커스텀 래퍼 → 비즈니스 로직
2. **환경 분리**: webOS 실제 환경과 개발 환경을 자동으로 감지하여 처리
3. **싱글톤 패턴**: 인스턴스 재사용을 통한 메모리 효율성
4. **콜백 기반**: onSuccess, onFailure, onComplete 콜백으로 비동기 처리
5. **구독 지원**: subscribe 옵션으로 실시간 데이터 업데이트 수신
6. **타입 안전성**: 각 서비스 호출을 전용 함수로 래핑하여 타입 안전성 확보
7. **재사용성**: common.js, account.js로 기능별 모듈화
이러한 구조를 통해 webOS 시스템 서비스와의 안정적이고 효율적인 통신을 구현하고 있습니다.

View File

@@ -1,58 +0,0 @@
Response Data
Parameter Name Type Description 비고
result SearchResultInfoPOC Array
POC 검색 결과 목록 쇼퍼하우스 연계 dummy
8) Example
 Success (json)
- Body
HTTP/1.1 200 OK
X-Server-Time: 1284366813334
Content-Type: application/json
{
“retCode”: 0,
“retMsg”: “Success”
"data": {
"result": {
"time": "25 ms",
"results": [
{
"docs": [
{
"dcPrice": "$ 69.99",
"thumbnail": "https://media.us.lg.com/transform/ecomm-PDPGallery-1100x730/e9b7c49b-66ed-45d4-8890-dd32c91a2053/TV-accessories_WS25XA_gallery-01_3000x3000",
"reviewGrade": "",
"partnerName": "LGE",
"partnerLogo": "http://aic-ngfts.lge.com/fts/gftsDownload.lge?biz_code=LGSHOPPING&func_code=IMAGE&file_path=/lgshopping/image/us_obs_logo_60x60.png",
"price": "$ 69.99",
"contentId": "V3_8001_Tv Search_PD_9_WS25XA",
"title": "StandbyME 2 Carry Strap & Wall-Mount Holder",
"soldout": "N",
"rankInfo": 1,
"euEnrgLblInfos": [ ]
}
],
"total_count": 100,
"type": "item",
"hit_count": 100,
"searchId": "SEARCH_uCS3z1N0QgtRXjsyhDCpA0R80",
"sortingType": "LG_RECOMMENDED",
"rangeType": “SIMILAR",
"createdAt": “2025-09-23 13:23:11",
"relativeQuerys": [
"What are some luxury skincare products",
"What are some luxury skincare products"
]
}
],
"httpCode": 200
}
}
}
 Error Code
- json 응답의 { retCode: {error_code}, retMsg: {error_msg} } 에 입력되는 code값
Error Code Error Msg Description

View File

@@ -1,562 +0,0 @@
<div
style={{
width: '100%',
height: '365px',
padding: 60,
background:
'linear-gradient(270deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.62) 30%, rgba(0, 0, 0, 0) 65%), linear-gradient(0deg, rgba(0, 0, 0, 0.53) 0%, rgba(20.56, 4.68, 32.71, 0.53) 60%, rgba(199, 32, 84, 0) 98%), rgba(0, 0, 0, 0.56), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, black 45%, black 100%)',
overflow: 'hidden',
borderTop: '1px rgba(234.30, 234.30, 234.30, 0.80) solid',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 20,
display: 'inline-flex',
}}
>
<div
style={{
width: 300,
height: 70,
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
overflow: 'hidden',
borderRadius: 100,
outline: '1px rgba(234.30, 234.30, 234.30, 0.80) solid',
outlineOffset: '-1px',
justifyContent: 'space-between',
alignItems: 'center',
display: 'inline-flex',
}}
>
<div
style={{
color: 'white',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '600',
lineHeight: 35,
wordWrap: 'break-word',
}}
>
LIVE CHANNEL
</div>
<div style={{ width: 26.25, height: 15.63, background: 'white' }} />
</div>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 30,
display: 'inline-flex',
}}
>
<div
style={{
width: 470,
padding: 18,
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
borderRadius: 12,
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'flex',
}}
>
<img
style={{
flex: '1 1 0',
height: 119.25,
paddingLeft: 0.41,
paddingRight: 0.41,
paddingTop: 0.51,
paddingBottom: 0.51,
}}
src="https://placehold.co/212x119"
/>
<div
style={{
flex: '1 1 0',
height: 110,
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
display: 'inline-flex',
}}
>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 9,
display: 'inline-flex',
}}
>
<div
style={{
width: 40,
height: 40,
position: 'relative',
background: 'white',
borderRadius: 100,
}}
>
<img
style={{ width: 20.67, height: 20.67, left: 9.67, top: 9.67, position: 'absolute' }}
src="https://placehold.co/21x21"
/>
</div>
<div
style={{
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
QVC
</div>
</div>
<div
style={{
alignSelf: 'stretch',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '400',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
JAI Jewelry
</div>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'inline-flex',
}}
>
<div
style={{
flex: '1 1 0',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 11,
display: 'flex',
}}
>
<div style={{ flex: '1 1 0', height: 5, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 212,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#D4D4D4',
}}
/>
<div
style={{
width: 137.66,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#7D848C',
}}
/>
</div>
</div>
</div>
</div>
</div>
<div
style={{
width: 470,
padding: 18,
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
borderRadius: 12,
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'flex',
}}
>
<img
style={{
flex: '1 1 0',
height: 119.25,
paddingLeft: 0.41,
paddingRight: 0.41,
paddingTop: 0.51,
paddingBottom: 0.51,
}}
src="https://placehold.co/212x119"
/>
<div
style={{
flex: '1 1 0',
height: 110,
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
display: 'inline-flex',
}}
>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 9,
display: 'inline-flex',
}}
>
<div
style={{
width: 40,
height: 40,
position: 'relative',
background: 'white',
borderRadius: 100,
}}
>
<img
style={{ width: 30.7, height: 30.7, left: 4.65, top: 4.65, position: 'absolute' }}
src="https://placehold.co/31x31"
/>
</div>
<div
style={{
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
ShopLC
</div>
</div>
<div
style={{
alignSelf: 'stretch',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '400',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
JAI Jewelry
</div>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'inline-flex',
}}
>
<div
style={{
flex: '1 1 0',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 11,
display: 'flex',
}}
>
<div style={{ flex: '1 1 0', height: 5, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 212,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#D4D4D4',
}}
/>
<div
style={{
width: 137.66,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#7D848C',
}}
/>
</div>
</div>
</div>
</div>
</div>
<div
style={{
width: 470,
padding: 18,
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
borderRadius: 12,
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'flex',
}}
>
<img
style={{
flex: '1 1 0',
height: 119.25,
paddingLeft: 0.41,
paddingRight: 0.41,
paddingTop: 0.51,
paddingBottom: 0.51,
}}
src="https://placehold.co/212x119"
/>
<div
style={{
flex: '1 1 0',
height: 110,
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
display: 'inline-flex',
}}
>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 9,
display: 'inline-flex',
}}
>
<div
style={{
width: 40,
height: 40,
position: 'relative',
background: '#1E77BC',
borderRadius: 100,
}}
>
<img
style={{ width: 30.37, height: 30.37, left: 4.82, top: 4.82, position: 'absolute' }}
src="https://placehold.co/30x30"
/>
</div>
<div
style={{
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
HSN
</div>
</div>
<div
style={{
alignSelf: 'stretch',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '400',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
JAI Jewelry
</div>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'inline-flex',
}}
>
<div
style={{
flex: '1 1 0',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 11,
display: 'flex',
}}
>
<div style={{ flex: '1 1 0', height: 5, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 212,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#D4D4D4',
}}
/>
<div
style={{
width: 137.66,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#7D848C',
}}
/>
</div>
</div>
</div>
</div>
</div>
<div
style={{
width: 470,
padding: 18,
background: 'linear-gradient(0deg, 0%, 100%), #2C2C2C',
borderRadius: 12,
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'flex',
}}
>
<img
style={{
flex: '1 1 0',
height: 119.25,
paddingLeft: 0.41,
paddingRight: 0.41,
paddingTop: 0.51,
paddingBottom: 0.51,
}}
src="https://placehold.co/212x119"
/>
<div
style={{
flex: '1 1 0',
height: 110,
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
display: 'inline-flex',
}}
>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 9,
display: 'inline-flex',
}}
>
<div
style={{
width: 40,
height: 40,
position: 'relative',
background: 'white',
borderRadius: 100,
}}
>
<img
style={{ width: 20.67, height: 20.67, left: 9.67, top: 9.67, position: 'absolute' }}
src="https://placehold.co/21x21"
/>
</div>
<div
style={{
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
QVC
</div>
</div>
<div
style={{
alignSelf: 'stretch',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '400',
lineHeight: 31,
wordWrap: 'break-word',
}}
>
JAI Jewelry
</div>
<div
style={{
alignSelf: 'stretch',
justifyContent: 'flex-start',
alignItems: 'flex-start',
gap: 10,
display: 'inline-flex',
}}
>
<div
style={{
flex: '1 1 0',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 11,
display: 'flex',
}}
>
<div style={{ flex: '1 1 0', height: 5, position: 'relative', overflow: 'hidden' }}>
<div
style={{
width: 212,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#D4D4D4',
}}
/>
<div
style={{
width: 137.66,
height: 5,
left: 0,
top: 0,
position: 'absolute',
background: '#7D848C',
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>;

View File

@@ -0,0 +1,903 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import { getShopperHouseSearch } from '../../../actions/searchActions';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import TInput, { ICONS, KINDS } from '../TInput/TInput';
import { useWebSpeech } from '../../../hooks/useWebSpeech';
import VoiceListening from './modes/VoiceListening';
import VoiceNotRecognized from './modes/VoiceNotRecognized';
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
import VoicePromptScreen from './modes/VoicePromptScreen';
import VoiceResponse from './modes/VoiceResponse';
import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
restrict: 'self-only', // 포커스를 overlay 내부로만 제한
},
'div'
);
const SpottableMicButton = Spottable('div');
// Voice overlay 모드 상수
export const VOICE_MODES = {
PROMPT: 'prompt', // Try saying 화면
LISTENING: 'listening', // 듣는 중 화면
RESPONSE: 'response', // STT 텍스트 표시 화면
NOINIT: 'noinit', // 음성 인식이 초기화되지 않았을 때 화면
NOTRECOGNIZED: 'notrecognized', // 음성 인식이 되지 않았을 때 화면
MODE_3: 'mode3', // 추후 추가
MODE_4: 'mode4', // 추후 추가
};
// NOINIT 모드 에러 메시지
const NOINIT_ERROR_MESSAGE = 'Voice recognition is not supported on this device.';
// 음성인식 입력 모드 (VUI vs WebSpeech)
export const VOICE_INPUT_MODE = {
VUI: 'vui', // VUI (Voice UI Framework)
WEBSPEECH: 'webspeech', // Web Speech API
};
// Voice Version 상수 (어떤 음성 시스템을 사용할지 결정)
export const VOICE_VERSION = {
WEB_SPEECH: 'webspeech', // 1번: Web Speech API (기본값)
VUI: 'vui', // 2번: VUI Framework
};
const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container';
const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
const MIC_WEBSPEECH_SPOTLIGHT_ID = 'voice-overlay-mic-webspeech-button';
// 🔧 실험적 기능: Wake Word Detection ("Hey Shoptime")
// false로 설정하면 이 기능은 완전히 비활성화됩니다
const ENABLE_WAKE_WORD = false;
// 🔧 실험적 기능: Beep Sound on Listening Start
// false로 설정하면 Beep 소리가 재생되지 않습니다
const ENABLE_BEEP_SOUND = true;
const VoiceInputOverlay = ({
isVisible,
onClose,
mode = VOICE_MODES.PROMPT,
suggestions = [],
searchQuery = '',
onSearchChange,
onSearchSubmit,
}) => {
const dispatch = useDispatch();
const lastFocusedElement = useRef(null);
const listeningTimerRef = useRef(null);
const audioContextRef = useRef(null);
// Timer refs for cleanup
const closeTimerRef = useRef(null);
const focusTimerRef = useRef(null);
const focusRestoreTimerRef = useRef(null);
const searchSubmitFocusTimerRef = useRef(null);
const wakeWordRestartTimerRef = useRef(null);
const [inputFocus, setInputFocus] = useState(false);
const [micFocused, setMicFocused] = useState(false);
const [micWebSpeechFocused, setMicWebSpeechFocused] = useState(false);
// 내부 모드 상태 관리 (prompt -> listening -> response -> close)
const [currentMode, setCurrentMode] = useState(mode);
// 음성인식 입력 모드 (VUI vs WebSpeech)
const [voiceInputMode, setVoiceInputMode] = useState(null);
// STT 응답 텍스트 저장
const [sttResponseText, setSttResponseText] = useState('');
// Voice Version (어떤 음성 시스템을 사용할지 결정)
const [voiceVersion, setVoiceVersion] = useState(VOICE_VERSION.WEB_SPEECH);
// 🔊 Beep 소리 재생 함수
const playBeep = useCallback(() => {
if (!ENABLE_BEEP_SOUND) return;
try {
// AudioContext 지원 여부 확인 (TV 환경에서는 지원되지 않을 수 있음)
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
console.warn(
'[VoiceInputOverlay] AudioContext not supported in this environment, skipping beep'
);
return;
}
// AudioContext 생성 (재사용)
if (!audioContextRef.current) {
try {
audioContextRef.current = new AudioContextClass();
} catch (contextErr) {
console.warn('[VoiceInputOverlay] Failed to create AudioContext:', contextErr);
return;
}
}
const audioContext = audioContextRef.current;
// null 또는 undefined 체크
if (!audioContext) {
console.warn('[VoiceInputOverlay] AudioContext is null or undefined, skipping beep');
return;
}
// AudioContext 메서드 존재 여부 확인
if (
typeof audioContext.createOscillator !== 'function' ||
typeof audioContext.createGain !== 'function'
) {
console.warn('[VoiceInputOverlay] AudioContext methods not available, skipping beep');
return;
}
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // 800Hz (높은 피치)
oscillator.type = 'sine'; // 부드러운 소리
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // 볼륨 30%
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1); // 페이드아웃
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1); // 0.1초 재생
console.log('🔊 [VoiceInputOverlay] Beep sound played successfully');
} catch (err) {
// 어떤 오류가 발생하더라도 앱이 멈추지 않도록 조용히 처리
console.warn('[VoiceInputOverlay] Failed to play beep sound (non-critical):', err);
}
}, []);
// Web Speech API Hook (WebSpeech 모드일 때만 활성화)
const handleWebSpeechSTT = useCallback((sttText) => {
console.log('🎤 [VoiceInputOverlay] WebSpeech STT text received:', sttText);
// 타이머 중지
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
// STT 텍스트 저장
setSttResponseText(sttText);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
console.log('📺 [VoiceInputOverlay] Switching to RESPONSE mode with text:', sttText);
}, []);
const { isListening, interimText, startListening, stopListening, error, isSupported } =
useWebSpeech(
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
handleWebSpeechSTT,
{
lang: 'en-US',
continuous: false, // 침묵 감지 후 자동 종료
interimResults: true,
}
);
// ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지
// Redux에서 voice 상태 가져오기
// const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 shopperHouse 검색 결과 가져오기
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const shopperHouseDataRef = useRef(shopperHouseData);
// ShopperHouse API 응답 수신 시 overlay 닫기
useEffect(() => {
// 이전 값과 비교하여 새로운 데이터가 들어왔을 때만 닫기
if (isVisible && shopperHouseData && shopperHouseData !== shopperHouseDataRef.current) {
console.log('[VoiceInputOverlay] ShopperHouse data received, closing overlay');
shopperHouseDataRef.current = shopperHouseData;
// 약간의 지연 후 닫기 (사용자가 결과를 인지할 수 있도록)
closeTimerRef.current = setTimeout(() => {
onClose();
}, 500);
}
return () => {
// Cleanup: 컴포넌트 언마운트 또는 의존성 변경 시 타이머 정리
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [shopperHouseData, isVisible, onClose]);
// ⛔ VUI 테스트 비활성화: STT 텍스트 수신 처리
// STT 텍스트 수신 시 처리
// useEffect(() => {
// if (lastSTTText && sttTimestamp && isVisible) {
// console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// // 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
// if (onSearchChange) {
// onSearchChange({ value: lastSTTText });
// }
// // listening 모드로 전환 (시각적 피드백)
// setCurrentMode(VOICE_MODES.LISTENING);
// // 1초 후 자동 닫기 (선택사항)
// setTimeout(() => {
// onClose();
// }, 1000);
// }
// }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// 🎉 Wake Word Detection: PROMPT 모드에서 "Hey Shoptime" 감지
useEffect(() => {
if (!ENABLE_WAKE_WORD) return;
if (currentMode !== VOICE_MODES.PROMPT) return;
if (!interimText) return;
const text = interimText.toLowerCase().trim();
const wakeWords = ['hey shoptime', 'hey shop time', 'heyshoptime'];
// Wake Word 감지
const detected = wakeWords.some((word) => text.includes(word));
if (detected) {
console.log('🎉 [VoiceInputOverlay] Wake word detected in PROMPT mode:', text);
handleWakeWordDetected();
}
}, [interimText, currentMode, handleWakeWordDetected]);
// WebSpeech Interim 텍스트 로그 출력
useEffect(() => {
if (interimText && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.log('💬 [VoiceInputOverlay] WebSpeech Interim text:', interimText);
}
}, [interimText, voiceInputMode]);
// WebSpeech 에러 처리
useEffect(() => {
if (error && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.error('❌ [VoiceInputOverlay] WebSpeech Error:', error);
}
}, [error, voiceInputMode]);
// WebSpeech가 지원되지 않을 때 NOINIT 모드로 전환
useEffect(() => {
if (isVisible && voiceVersion === VOICE_VERSION.WEB_SPEECH && isSupported === false) {
console.log('⚠️ [VoiceInputOverlay] WebSpeech not supported, switching to NOINIT mode');
setCurrentMode(VOICE_MODES.NOINIT);
}
}, [isVisible, voiceVersion, isSupported]);
// WebSpeech listening 상태가 종료되어도 15초 타이머는 그대로 유지
// (음성 입력이 끝나도 listening 모드는 15초간 유지)
useEffect(() => {
if (!isListening && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.log('🎤 [VoiceInputOverlay] WebSpeech stopped, but LISTENING mode continues...');
// 타이머 정리하지 않음 - 15초가 끝날 때까지 listening 모드 유지
}
}, [isListening, voiceInputMode]);
// ⛔ 독립 테스트: WebSpeech API 호출 비활성화
// WebSpeech 모드로 전환되면 자동으로 음성 인식 시작
// useEffect(() => {
// if (
// voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
// currentMode === VOICE_MODES.LISTENING &&
// !isListening
// ) {
// console.log('🎙️ [VoiceInputOverlay] Auto-starting Web Speech API after mode change...');
// startListening();
// // 15초 타이머 설정
// if (listeningTimerRef.current) {
// clearTimeout(listeningTimerRef.current);
// }
// listeningTimerRef.current = setTimeout(() => {
// console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - WebSpeech 자동 종료');
// stopListening();
// setCurrentMode(VOICE_MODES.PROMPT);
// setVoiceInputMode(null);
// }, 15000); // 15초
// }
// }, [voiceInputMode, currentMode, isListening, startListening, stopListening]);
// 이 useEffect 제거 - renderModeContent()에서 직접 판단하므로 불필요
// 🎉 Wake Word Detection: PROMPT 모드에서 백그라운드 리스닝 시작
useEffect(() => {
if (!ENABLE_WAKE_WORD) return;
if (!isVisible) return;
if (currentMode !== VOICE_MODES.PROMPT) return;
console.log('🎙️ [VoiceInputOverlay] Starting background listening for wake word detection');
// PROMPT 모드에서 백그라운드 리스닝 시작
startListening();
// Cleanup
return () => {
if (ENABLE_WAKE_WORD && currentMode === VOICE_MODES.PROMPT) {
stopListening();
}
};
}, [ENABLE_WAKE_WORD, isVisible, currentMode, startListening, stopListening]);
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
if (isVisible) {
// 현재 포커스된 요소 저장
lastFocusedElement.current = Spotlight.getCurrent();
// 모드 초기화 (항상 prompt 모드로 시작)
setCurrentMode(mode);
setVoiceInputMode(null);
// 마이크 버튼으로 포커스 이동
focusTimerRef.current = setTimeout(() => {
Spotlight.focus(MIC_SPOTLIGHT_ID);
}, 100);
} else {
// Overlay가 닫힐 때 원래 포커스 복원 및 상태 초기화
// 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
// ⛔ 독립 테스트: WebSpeech API 호출 비활성화
// WebSpeech 중지 (비동기로 처리)
// if (isListening) {
// stopListening();
// }
// 상태 초기화
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
if (lastFocusedElement.current) {
focusRestoreTimerRef.current = setTimeout(() => {
Spotlight.focus(lastFocusedElement.current);
}, 100);
}
}
return () => {
// Cleanup: 컴포넌트 언마운트 또는 isVisible 변경 시 타이머 정리
if (focusTimerRef.current) {
clearTimeout(focusTimerRef.current);
focusTimerRef.current = null;
}
if (focusRestoreTimerRef.current) {
clearTimeout(focusRestoreTimerRef.current);
focusRestoreTimerRef.current = null;
}
};
}, [isVisible, mode]);
// Cleanup all timers and AudioContext on component unmount
useEffect(() => {
return () => {
// Clear all timer refs
if (searchSubmitFocusTimerRef.current) {
clearTimeout(searchSubmitFocusTimerRef.current);
searchSubmitFocusTimerRef.current = null;
}
if (wakeWordRestartTimerRef.current) {
clearTimeout(wakeWordRestartTimerRef.current);
wakeWordRestartTimerRef.current = null;
}
// Close AudioContext to free audio resources
if (audioContextRef.current) {
try {
if (typeof audioContextRef.current.close === 'function') {
audioContextRef.current.close();
}
} catch (err) {
console.warn('[VoiceInputOverlay] Failed to close AudioContext:', err);
}
audioContextRef.current = null;
}
};
}, []);
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
const handleSuggestionClick = useCallback(
(suggestion) => {
console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion);
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, '').trim();
// Input 창에 텍스트 설정
if (onSearchChange) {
onSearchChange({ value: query });
}
},
[onSearchChange]
);
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
const handleSearchSubmit = useCallback(() => {
console.log('[VoiceInputOverlay] Search submit:', searchQuery);
if (searchQuery && searchQuery.trim()) {
// ShopperHouse API 호출
dispatch(getShopperHouseSearch(searchQuery.trim()));
// Input 내용 비우기
if (onSearchChange) {
onSearchChange({ value: '' });
}
// Clear existing timer before setting new one
if (searchSubmitFocusTimerRef.current) {
clearTimeout(searchSubmitFocusTimerRef.current);
}
// API 호출 후 Input 박스로 포커스 이동
searchSubmitFocusTimerRef.current = setTimeout(() => {
Spotlight.focus(INPUT_SPOTLIGHT_ID);
}, 100);
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
// if (onSearchSubmit) {
// onSearchSubmit(searchQuery);
// }
}
}, [dispatch, searchQuery, onSearchChange]);
// Input 창에서 엔터키 핸들러 (API 호출하지 않음)
const handleInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
// Enter 키로는 API 호출하지 않음
// 돋보기 아이콘 클릭/Enter로만 API 호출
}
}, []);
// 🎉 Wake Word Detection: "Hey Shoptime" 감지 시 자동으로 LISTENING 모드로 전환
const handleWakeWordDetected = useCallback(() => {
if (!ENABLE_WAKE_WORD) return;
console.log('🎉 [VoiceInputOverlay] Wake word detected! Switching to LISTENING mode');
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
if (wakeWordRestartTimerRef.current) {
clearTimeout(wakeWordRestartTimerRef.current);
wakeWordRestartTimerRef.current = null;
}
// LISTENING 모드로 전환
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech 재시작 (continuous: false로 일반 입력 모드)
stopListening();
wakeWordRestartTimerRef.current = setTimeout(() => {
startListening();
wakeWordRestartTimerRef.current = null;
}, 300);
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
stopListening();
}, 15000);
}, [startListening, stopListening]);
// TALK AGAIN 버튼 핸들러
const handleTalkAgain = useCallback(() => {
console.log('🎤 [VoiceInputOverlay] TALK AGAIN - Restarting LISTENING mode');
// 🔊 Beep 소리 재생
playBeep();
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
// STT 텍스트 초기화
setSttResponseText('');
// LISTENING 모드로 전환
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech API 시작
startListening();
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
stopListening();
}, 15000);
}, [playBeep, startListening, stopListening]);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
console.log(
'📺 [VoiceInputOverlay] renderModeContent - currentMode:',
currentMode,
'voiceInputMode:',
voiceInputMode,
'isListening:',
isListening
);
switch (currentMode) {
case VOICE_MODES.PROMPT:
console.log('📺 Rendering: VoicePromptScreen');
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
case VOICE_MODES.LISTENING:
console.log('📺 Rendering: VoiceListening (15초 타이머 기반)');
return <VoiceListening interimText={interimText} />;
case VOICE_MODES.RESPONSE:
console.log('📺 Rendering: VoiceResponse with text:', sttResponseText);
return <VoiceResponse responseText={sttResponseText} onTalkAgain={handleTalkAgain} />;
case VOICE_MODES.NOINIT:
console.log('📺 Rendering: VoiceNotRecognized (NOINIT mode)');
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
case VOICE_MODES.NOTRECOGNIZED:
console.log('📺 Rendering: VoiceNotRecognized (NOTRECOGNIZED mode)');
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_4:
// 추후 MODE_4 컴포넌트 추가
return <VoiceNotRecognizedCircle />;
default:
console.log('📺 Rendering: VoicePromptScreen (default)');
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
}
};
// 입력창 포커스 핸들러
const handleInputFocus = useCallback(() => {
setInputFocus(true);
}, []);
const handleInputBlur = useCallback(() => {
setInputFocus(false);
}, []);
// 마이크 버튼 포커스 핸들러 (VUI)
const handleMicFocus = useCallback(() => {
setMicFocused(true);
}, []);
const handleMicBlur = useCallback(() => {
setMicFocused(false);
}, []);
// WebSpeech 마이크 버튼 포커스 핸들러
const handleMicWebSpeechFocus = useCallback(() => {
setMicWebSpeechFocused(true);
}, []);
const handleMicWebSpeechBlur = useCallback(() => {
setMicWebSpeechFocused(false);
}, []);
// VUI 마이크 버튼 클릭 핸들러 (voiceVersion이 VUI일 때만 작동)
const handleVUIMicClick = useCallback(
(e) => {
// voiceVersion이 VUI가 아니면 차단
if (voiceVersion !== VOICE_VERSION.VUI) return;
console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
e.stopPropagation();
}
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> VUI listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode');
setVoiceInputMode(VOICE_INPUT_MODE.VUI);
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) {
// VUI listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode');
setVoiceInputMode(null);
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
setVoiceInputMode(null);
onClose();
}
},
[currentMode, voiceInputMode, voiceVersion, onClose]
);
// Overlay 닫기 핸들러 (모든 닫기 동작을 통합)
const handleClose = useCallback(() => {
console.log('[VoiceInputOverlay] Closing overlay');
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
setSttResponseText('');
onClose();
}, [onClose]);
// WebSpeech 마이크 버튼 클릭 핸들러
const handleWebSpeechMicClick = useCallback(
(e) => {
console.log(
'🎤 [VoiceInputOverlay] handleWebSpeechMicClick called, currentMode:',
currentMode
);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
e.stopPropagation();
}
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT || currentMode === VOICE_MODES.RESPONSE) {
// prompt 모드 또는 response 모드에서 클릭 시:
// 1. listening 모드로 전환 (15초 타이머)
// 2. WebSpeech API 시작 (독립 동작)
console.log('🎤 [VoiceInputOverlay] Starting LISTENING mode (15s) + WebSpeech API');
// 🔊 Beep 소리 재생
playBeep();
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
// STT 텍스트 초기화 (RESPONSE 모드에서 올 경우)
if (currentMode === VOICE_MODES.RESPONSE) {
setSttResponseText('');
}
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech API 시작
startListening();
// 15초 타이머 설정 (WebSpeech 종료와 무관하게 15초 후 PROMPT 복귀)
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
// WebSpeech가 아직 동작 중이면 중지
stopListening();
}, 15000); // 15초
} else {
// listening 모드 또는 기타 모드에서 클릭 시 -> overlay 닫기
console.log('🎤 [VoiceInputOverlay] Closing overlay');
handleClose();
}
},
[currentMode, handleClose, playBeep, startListening, stopListening]
);
return (
<TFullPopup
open={isVisible}
onClose={handleClose}
noAutoDismiss={true}
spotlightRestrict="self-only"
spotlightId={OVERLAY_SPOTLIGHT_ID}
noAnimation={false}
scrimType="transparent"
className={css.tFullPopupWrapper}
>
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={handleClose} />
{/* 디버깅용: Voice 상태 표시 */}
<div
style={{
position: 'absolute',
top: 10,
right: 10,
color: '#fff',
zIndex: 10000,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: '10px',
borderRadius: '5px',
fontSize: '14px',
}}
>
<div>Voice Version: {voiceVersion}</div>
<div>Input Mode: {voiceInputMode || 'None'}</div>
<div>Current Mode: {currentMode}</div>
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<>
<div>isListening: {isListening ? '🎤 YES' : '❌ NO'}</div>
<div>Interim: {interimText || 'N/A'}</div>
</>
)}
</div>
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
className={css.contentArea}
spotlightId={OVERLAY_SPOTLIGHT_ID}
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div className={css.inputWrapper} onClick={(e) => e.stopPropagation()}>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
value={searchQuery}
onChange={onSearchChange}
onKeyDown={handleInputKeyDown}
onIconClick={handleSearchSubmit}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{/* voiceVersion에 따라 하나의 마이크만 표시 */}
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micFocused && css.focused
)}
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
)}
{/* VUI_DISABLE_START - VUI 마이크 버튼 비활성화 */}
{/* {voiceVersion === VOICE_VERSION.VUI && (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI &&
css.listening,
micFocused && css.focused
)}
onClick={handleVUIMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleVUIMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice AI" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
)} */}
{/* VUI_DISABLE_END */}
</div>
</div>
{/* 모드별 컨텐츠 */}
<div className={css.modeContent}>{renderModeContent()}</div>
</OverlayContainer>
</div>
</TFullPopup>
);
};
VoiceInputOverlay.propTypes = {
isVisible: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
mode: PropTypes.oneOf(Object.values(VOICE_MODES)),
suggestions: PropTypes.arrayOf(PropTypes.string),
searchQuery: PropTypes.string,
onSearchChange: PropTypes.func,
onSearchSubmit: PropTypes.func,
};
VoiceInputOverlay.defaultProps = {
mode: VOICE_MODES.PROMPT,
suggestions: [],
searchQuery: '',
onSearchChange: null,
onSearchSubmit: null,
};
export default VoiceInputOverlay;

View File

@@ -1,5 +1,5 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.v2.jsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
@@ -31,6 +31,9 @@ const OverlayContainer = SpotlightContainerDecorator(
const SpottableMicButton = Spottable('div');
// Debug mode constant
const DEBUG_MODE = process.env.NODE_ENV === 'development';
// Voice overlay 모드 상수
export const VOICE_MODES = {
PROMPT: 'prompt', // Try saying 화면
@@ -70,6 +73,19 @@ const ENABLE_WAKE_WORD = false;
// false로 설정하면 Beep 소리가 재생되지 않습니다
const ENABLE_BEEP_SOUND = true;
// Utility function to clear a single timer ref
const clearTimerRef = (timerRef) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
// Utility function to clear all timers at once
const clearAllTimers = (timerRefs) => {
timerRefs.forEach((timerRef) => clearTimerRef(timerRef));
};
const VoiceInputOverlay = ({
isVisible,
onClose,
@@ -91,9 +107,18 @@ const VoiceInputOverlay = ({
const searchSubmitFocusTimerRef = useRef(null);
const wakeWordRestartTimerRef = useRef(null);
// All timer refs array for batch cleanup
const allTimerRefs = [
listeningTimerRef,
closeTimerRef,
focusTimerRef,
focusRestoreTimerRef,
searchSubmitFocusTimerRef,
wakeWordRestartTimerRef,
];
const [inputFocus, setInputFocus] = useState(false);
const [micFocused, setMicFocused] = useState(false);
const [micWebSpeechFocused, setMicWebSpeechFocused] = useState(false);
// 내부 모드 상태 관리 (prompt -> listening -> response -> close)
const [currentMode, setCurrentMode] = useState(mode);
// 음성인식 입력 모드 (VUI vs WebSpeech)
@@ -103,7 +128,7 @@ const VoiceInputOverlay = ({
// Voice Version (어떤 음성 시스템을 사용할지 결정)
const [voiceVersion, setVoiceVersion] = useState(VOICE_VERSION.WEB_SPEECH);
// 🔊 Beep 소리 재생 함수
// 🔊 Beep 소리 재생 함수 - zero dependencies
const playBeep = useCallback(() => {
if (!ENABLE_BEEP_SOUND) return;
@@ -111,9 +136,11 @@ const VoiceInputOverlay = ({
// AudioContext 지원 여부 확인 (TV 환경에서는 지원되지 않을 수 있음)
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
console.warn(
'[VoiceInputOverlay] AudioContext not supported in this environment, skipping beep'
);
if (DEBUG_MODE) {
console.warn(
'[VoiceInputOverlay.v2] AudioContext not supported in this environment, skipping beep'
);
}
return;
}
@@ -122,7 +149,9 @@ const VoiceInputOverlay = ({
try {
audioContextRef.current = new AudioContextClass();
} catch (contextErr) {
console.warn('[VoiceInputOverlay] Failed to create AudioContext:', contextErr);
if (DEBUG_MODE) {
console.warn('[VoiceInputOverlay.v2] Failed to create AudioContext:', contextErr);
}
return;
}
}
@@ -131,7 +160,9 @@ const VoiceInputOverlay = ({
// null 또는 undefined 체크
if (!audioContext) {
console.warn('[VoiceInputOverlay] AudioContext is null or undefined, skipping beep');
if (DEBUG_MODE) {
console.warn('[VoiceInputOverlay.v2] AudioContext is null or undefined, skipping beep');
}
return;
}
@@ -140,7 +171,9 @@ const VoiceInputOverlay = ({
typeof audioContext.createOscillator !== 'function' ||
typeof audioContext.createGain !== 'function'
) {
console.warn('[VoiceInputOverlay] AudioContext methods not available, skipping beep');
if (DEBUG_MODE) {
console.warn('[VoiceInputOverlay.v2] AudioContext methods not available, skipping beep');
}
return;
}
@@ -159,29 +192,34 @@ const VoiceInputOverlay = ({
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1); // 0.1초 재생
console.log('🔊 [VoiceInputOverlay] Beep sound played successfully');
if (DEBUG_MODE) {
console.log('🔊 [VoiceInputOverlay.v2] Beep sound played successfully');
}
} catch (err) {
// 어떤 오류가 발생하더라도 앱이 멈추지 않도록 조용히 처리
console.warn('[VoiceInputOverlay] Failed to play beep sound (non-critical):', err);
if (DEBUG_MODE) {
console.warn('[VoiceInputOverlay.v2] Failed to play beep sound (non-critical):', err);
}
}
}, []);
// Web Speech API Hook (WebSpeech 모드일 때만 활성화)
// Web Speech API Hook (WebSpeech 모드일 때만 활성화) - zero dependencies
const handleWebSpeechSTT = useCallback((sttText) => {
console.log('🎤 [VoiceInputOverlay] WebSpeech STT text received:', sttText);
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] WebSpeech STT text received:', sttText);
}
// 타이머 중지
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
clearTimerRef(listeningTimerRef);
// STT 텍스트 저장
setSttResponseText(sttText);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
console.log('📺 [VoiceInputOverlay] Switching to RESPONSE mode with text:', sttText);
if (DEBUG_MODE) {
console.log('📺 [VoiceInputOverlay.v2] Switching to RESPONSE mode with text:', sttText);
}
}, []);
const { isListening, interimText, startListening, stopListening, error, isSupported } =
@@ -199,15 +237,17 @@ const VoiceInputOverlay = ({
// Redux에서 voice 상태 가져오기
// const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 shopperHouse 검색 결과 가져오기
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const shopperHouseDataRef = useRef(shopperHouseData);
const shopperHouseDataRef = useRef(null);
// ShopperHouse API 응답 수신 시 overlay 닫기
useEffect(() => {
// 이전 값과 비교하여 새로운 데이터가 들어왔을 때만 닫기
if (isVisible && shopperHouseData && shopperHouseData !== shopperHouseDataRef.current) {
console.log('[VoiceInputOverlay] ShopperHouse data received, closing overlay');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] ShopperHouse data received, closing overlay');
}
shopperHouseDataRef.current = shopperHouseData;
// 약간의 지연 후 닫기 (사용자가 결과를 인지할 수 있도록)
@@ -218,10 +258,7 @@ const VoiceInputOverlay = ({
return () => {
// Cleanup: 컴포넌트 언마운트 또는 의존성 변경 시 타이머 정리
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
clearTimerRef(closeTimerRef);
};
}, [shopperHouseData, isVisible, onClose]);
@@ -229,16 +266,16 @@ const VoiceInputOverlay = ({
// STT 텍스트 수신 시 처리
// useEffect(() => {
// if (lastSTTText && sttTimestamp && isVisible) {
// console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// console.log('[VoiceInputOverlay.v2] STT text received in overlay:', lastSTTText);
//
// // 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
// if (onSearchChange) {
// onSearchChange({ value: lastSTTText });
// }
//
// // listening 모드로 전환 (시각적 피드백)
// setCurrentMode(VOICE_MODES.LISTENING);
//
// // 1초 후 자동 닫기 (선택사항)
// setTimeout(() => {
// onClose();
@@ -246,6 +283,41 @@ const VoiceInputOverlay = ({
// }
// }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// 🎉 Wake Word Detection: "Hey Shoptime" 감지 시 자동으로 LISTENING 모드로 전환
const handleWakeWordDetected = useCallback(() => {
if (!ENABLE_WAKE_WORD) return;
if (DEBUG_MODE) {
console.log('🎉 [VoiceInputOverlay.v2] Wake word detected! Switching to LISTENING mode');
}
// 기존 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(wakeWordRestartTimerRef);
// LISTENING 모드로 전환
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech 재시작 (continuous: false로 일반 입력 모드)
stopListening();
wakeWordRestartTimerRef.current = setTimeout(() => {
startListening();
wakeWordRestartTimerRef.current = null;
}, 300);
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
if (DEBUG_MODE) {
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - PROMPT 모드로 복귀');
}
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
stopListening();
}, 15000);
}, [startListening, stopListening]);
// 🎉 Wake Word Detection: PROMPT 모드에서 "Hey Shoptime" 감지
useEffect(() => {
if (!ENABLE_WAKE_WORD) return;
@@ -259,42 +331,23 @@ const VoiceInputOverlay = ({
const detected = wakeWords.some((word) => text.includes(word));
if (detected) {
console.log('🎉 [VoiceInputOverlay] Wake word detected in PROMPT mode:', text);
if (DEBUG_MODE) {
console.log('🎉 [VoiceInputOverlay.v2] Wake word detected in PROMPT mode:', text);
}
handleWakeWordDetected();
}
}, [interimText, currentMode, handleWakeWordDetected]);
// WebSpeech Interim 텍스트 로그 출력
useEffect(() => {
if (interimText && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.log('💬 [VoiceInputOverlay] WebSpeech Interim text:', interimText);
}
}, [interimText, voiceInputMode]);
// WebSpeech 에러 처리
useEffect(() => {
if (error && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.error('❌ [VoiceInputOverlay] WebSpeech Error:', error);
}
}, [error, voiceInputMode]);
// WebSpeech가 지원되지 않을 때 NOINIT 모드로 전환
useEffect(() => {
if (isVisible && voiceVersion === VOICE_VERSION.WEB_SPEECH && isSupported === false) {
console.log('⚠️ [VoiceInputOverlay] WebSpeech not supported, switching to NOINIT mode');
if (DEBUG_MODE) {
console.log('⚠️ [VoiceInputOverlay.v2] WebSpeech not supported, switching to NOINIT mode');
}
setCurrentMode(VOICE_MODES.NOINIT);
}
}, [isVisible, voiceVersion, isSupported]);
// WebSpeech listening 상태가 종료되어도 15초 타이머는 그대로 유지
// (음성 입력이 끝나도 listening 모드는 15초간 유지)
useEffect(() => {
if (!isListening && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
console.log('🎤 [VoiceInputOverlay] WebSpeech stopped, but LISTENING mode continues...');
// 타이머 정리하지 않음 - 15초가 끝날 때까지 listening 모드 유지
}
}, [isListening, voiceInputMode]);
// ⛔ 독립 테스트: WebSpeech API 호출 비활성화
// WebSpeech 모드로 전환되면 자동으로 음성 인식 시작
// useEffect(() => {
@@ -303,15 +356,13 @@ const VoiceInputOverlay = ({
// currentMode === VOICE_MODES.LISTENING &&
// !isListening
// ) {
// console.log('🎙️ [VoiceInputOverlay] Auto-starting Web Speech API after mode change...');
// console.log('🎙️ [VoiceInputOverlay.v2] Auto-starting Web Speech API after mode change...');
// startListening();
//
// // 15초 타이머 설정
// if (listeningTimerRef.current) {
// clearTimeout(listeningTimerRef.current);
// }
// clearTimerRef(listeningTimerRef);
// listeningTimerRef.current = setTimeout(() => {
// console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - WebSpeech 자동 종료');
// console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - WebSpeech 자동 종료');
// stopListening();
// setCurrentMode(VOICE_MODES.PROMPT);
// setVoiceInputMode(null);
@@ -327,7 +378,11 @@ const VoiceInputOverlay = ({
if (!isVisible) return;
if (currentMode !== VOICE_MODES.PROMPT) return;
console.log('🎙️ [VoiceInputOverlay] Starting background listening for wake word detection');
if (DEBUG_MODE) {
console.log(
'🎙️ [VoiceInputOverlay.v2] Starting background listening for wake word detection'
);
}
// PROMPT 모드에서 백그라운드 리스닝 시작
startListening();
@@ -358,10 +413,7 @@ const VoiceInputOverlay = ({
// Overlay가 닫힐 때 원래 포커스 복원 및 상태 초기화
// 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
clearTimerRef(listeningTimerRef);
// ⛔ 독립 테스트: WebSpeech API 호출 비활성화
// WebSpeech 중지 (비동기로 처리)
@@ -382,14 +434,8 @@ const VoiceInputOverlay = ({
return () => {
// Cleanup: 컴포넌트 언마운트 또는 isVisible 변경 시 타이머 정리
if (focusTimerRef.current) {
clearTimeout(focusTimerRef.current);
focusTimerRef.current = null;
}
if (focusRestoreTimerRef.current) {
clearTimeout(focusRestoreTimerRef.current);
focusRestoreTimerRef.current = null;
}
clearTimerRef(focusTimerRef);
clearTimerRef(focusRestoreTimerRef);
};
}, [isVisible, mode]);
@@ -397,14 +443,7 @@ const VoiceInputOverlay = ({
useEffect(() => {
return () => {
// Clear all timer refs
if (searchSubmitFocusTimerRef.current) {
clearTimeout(searchSubmitFocusTimerRef.current);
searchSubmitFocusTimerRef.current = null;
}
if (wakeWordRestartTimerRef.current) {
clearTimeout(wakeWordRestartTimerRef.current);
wakeWordRestartTimerRef.current = null;
}
clearAllTimers(allTimerRefs);
// Close AudioContext to free audio resources
if (audioContextRef.current) {
@@ -413,7 +452,9 @@ const VoiceInputOverlay = ({
audioContextRef.current.close();
}
} catch (err) {
console.warn('[VoiceInputOverlay] Failed to close AudioContext:', err);
if (DEBUG_MODE) {
console.warn('[VoiceInputOverlay.v2] Failed to close AudioContext:', err);
}
}
audioContextRef.current = null;
}
@@ -423,7 +464,9 @@ const VoiceInputOverlay = ({
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
const handleSuggestionClick = useCallback(
(suggestion) => {
console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion);
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Suggestion clicked:', suggestion);
}
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, '').trim();
// Input 창에 텍스트 설정
@@ -436,7 +479,9 @@ const VoiceInputOverlay = ({
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
const handleSearchSubmit = useCallback(() => {
console.log('[VoiceInputOverlay] Search submit:', searchQuery);
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Search submit:', searchQuery);
}
if (searchQuery && searchQuery.trim()) {
// ShopperHouse API 호출
dispatch(getShopperHouseSearch(searchQuery.trim()));
@@ -447,9 +492,7 @@ const VoiceInputOverlay = ({
}
// Clear existing timer before setting new one
if (searchSubmitFocusTimerRef.current) {
clearTimeout(searchSubmitFocusTimerRef.current);
}
clearTimerRef(searchSubmitFocusTimerRef);
// API 호출 후 Input 박스로 포커스 이동
searchSubmitFocusTimerRef.current = setTimeout(() => {
@@ -472,55 +515,17 @@ const VoiceInputOverlay = ({
}
}, []);
// 🎉 Wake Word Detection: "Hey Shoptime" 감지 시 자동으로 LISTENING 모드로 전환
const handleWakeWordDetected = useCallback(() => {
if (!ENABLE_WAKE_WORD) return;
console.log('🎉 [VoiceInputOverlay] Wake word detected! Switching to LISTENING mode');
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
if (wakeWordRestartTimerRef.current) {
clearTimeout(wakeWordRestartTimerRef.current);
wakeWordRestartTimerRef.current = null;
}
// LISTENING 모드로 전환
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// WebSpeech 재시작 (continuous: false로 일반 입력 모드)
stopListening();
wakeWordRestartTimerRef.current = setTimeout(() => {
startListening();
wakeWordRestartTimerRef.current = null;
}, 300);
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
stopListening();
}, 15000);
}, [startListening, stopListening]);
// TALK AGAIN 버튼 핸들러
const handleTalkAgain = useCallback(() => {
console.log('🎤 [VoiceInputOverlay] TALK AGAIN - Restarting LISTENING mode');
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] TALK AGAIN - Restarting LISTENING mode');
}
// 🔊 Beep 소리 재생
playBeep();
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
clearTimerRef(listeningTimerRef);
// STT 텍스트 초기화
setSttResponseText('');
@@ -534,7 +539,9 @@ const VoiceInputOverlay = ({
// 15초 타이머 설정
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
if (DEBUG_MODE) {
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - PROMPT 모드로 복귀');
}
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
@@ -542,34 +549,46 @@ const VoiceInputOverlay = ({
}, 15000);
}, [playBeep, startListening, stopListening]);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
console.log(
'📺 [VoiceInputOverlay] renderModeContent - currentMode:',
currentMode,
'voiceInputMode:',
voiceInputMode,
'isListening:',
isListening
);
// 모드에 따른 컨텐츠 렌더링 - Memoized
const renderModeContent = useMemo(() => {
if (DEBUG_MODE) {
console.log(
'📺 [VoiceInputOverlay.v2] renderModeContent - currentMode:',
currentMode,
'voiceInputMode:',
voiceInputMode,
'isListening:',
isListening
);
}
switch (currentMode) {
case VOICE_MODES.PROMPT:
console.log('📺 Rendering: VoicePromptScreen');
if (DEBUG_MODE) {
console.log('📺 Rendering: VoicePromptScreen');
}
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
case VOICE_MODES.LISTENING:
console.log('📺 Rendering: VoiceListening (15초 타이머 기반)');
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceListening (15초 타이머 기반)');
}
return <VoiceListening interimText={interimText} />;
case VOICE_MODES.RESPONSE:
console.log('📺 Rendering: VoiceResponse with text:', sttResponseText);
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceResponse with text:', sttResponseText);
}
return <VoiceResponse responseText={sttResponseText} onTalkAgain={handleTalkAgain} />;
case VOICE_MODES.NOINIT:
console.log('📺 Rendering: VoiceNotRecognized (NOINIT mode)');
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceNotRecognized (NOINIT mode)');
}
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
case VOICE_MODES.NOTRECOGNIZED:
console.log('📺 Rendering: VoiceNotRecognized (NOTRECOGNIZED mode)');
if (DEBUG_MODE) {
console.log('📺 Rendering: VoiceNotRecognized (NOTRECOGNIZED mode)');
}
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
@@ -578,12 +597,23 @@ const VoiceInputOverlay = ({
// 추후 MODE_4 컴포넌트 추가
return <VoiceNotRecognizedCircle />;
default:
console.log('📺 Rendering: VoicePromptScreen (default)');
if (DEBUG_MODE) {
console.log('📺 Rendering: VoicePromptScreen (default)');
}
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
}
};
}, [
currentMode,
voiceInputMode,
isListening,
suggestions,
handleSuggestionClick,
interimText,
sttResponseText,
handleTalkAgain,
]);
// 입력창 포커스 핸들러
const handleInputFocus = useCallback(() => {
@@ -603,22 +633,15 @@ const VoiceInputOverlay = ({
setMicFocused(false);
}, []);
// WebSpeech 마이크 버튼 포커스 핸들러
const handleMicWebSpeechFocus = useCallback(() => {
setMicWebSpeechFocused(true);
}, []);
const handleMicWebSpeechBlur = useCallback(() => {
setMicWebSpeechFocused(false);
}, []);
// VUI 마이크 버튼 클릭 핸들러 (voiceVersion이 VUI일 때만 작동)
const handleVUIMicClick = useCallback(
(e) => {
// voiceVersion이 VUI가 아니면 차단
if (voiceVersion !== VOICE_VERSION.VUI) return;
console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode);
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] handleVUIMicClick called, currentMode:', currentMode);
}
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
@@ -630,19 +653,25 @@ const VoiceInputOverlay = ({
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> VUI listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Switching to VUI LISTENING mode');
}
setVoiceInputMode(VOICE_INPUT_MODE.VUI);
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) {
// VUI listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Closing from VUI LISTENING mode');
}
setVoiceInputMode(null);
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Closing from other mode');
}
setVoiceInputMode(null);
onClose();
}
@@ -652,11 +681,10 @@ const VoiceInputOverlay = ({
// Overlay 닫기 핸들러 (모든 닫기 동작을 통합)
const handleClose = useCallback(() => {
console.log('[VoiceInputOverlay] Closing overlay');
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay.v2] Closing overlay');
}
clearTimerRef(listeningTimerRef);
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
setSttResponseText('');
@@ -666,10 +694,12 @@ const VoiceInputOverlay = ({
// WebSpeech 마이크 버튼 클릭 핸들러
const handleWebSpeechMicClick = useCallback(
(e) => {
console.log(
'🎤 [VoiceInputOverlay] handleWebSpeechMicClick called, currentMode:',
currentMode
);
if (DEBUG_MODE) {
console.log(
'🎤 [VoiceInputOverlay.v2] handleWebSpeechMicClick called, currentMode:',
currentMode
);
}
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
@@ -683,16 +713,15 @@ const VoiceInputOverlay = ({
// prompt 모드 또는 response 모드에서 클릭 시:
// 1. listening 모드로 전환 (15초 타이머)
// 2. WebSpeech API 시작 (독립 동작)
console.log('🎤 [VoiceInputOverlay] Starting LISTENING mode (15s) + WebSpeech API');
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] Starting LISTENING mode (15s) + WebSpeech API');
}
// 🔊 Beep 소리 재생
playBeep();
// 기존 타이머 정리
if (listeningTimerRef.current) {
clearTimeout(listeningTimerRef.current);
listeningTimerRef.current = null;
}
clearTimerRef(listeningTimerRef);
// STT 텍스트 초기화 (RESPONSE 모드에서 올 경우)
if (currentMode === VOICE_MODES.RESPONSE) {
@@ -707,7 +736,9 @@ const VoiceInputOverlay = ({
// 15초 타이머 설정 (WebSpeech 종료와 무관하게 15초 후 PROMPT 복귀)
listeningTimerRef.current = setTimeout(() => {
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
if (DEBUG_MODE) {
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - PROMPT 모드로 복귀');
}
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
@@ -716,13 +747,100 @@ const VoiceInputOverlay = ({
}, 15000); // 15초
} else {
// listening 모드 또는 기타 모드에서 클릭 시 -> overlay 닫기
console.log('🎤 [VoiceInputOverlay] Closing overlay');
if (DEBUG_MODE) {
console.log('🎤 [VoiceInputOverlay.v2] Closing overlay');
}
handleClose();
}
},
[currentMode, handleClose, playBeep, startListening, stopListening]
);
// Memoize microphone button rendering
const microphoneButton = useMemo(() => {
if (voiceVersion !== VOICE_VERSION.WEB_SPEECH) return null;
return (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micFocused && css.focused
)}
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
);
}, [
voiceVersion,
currentMode,
voiceInputMode,
micFocused,
handleWebSpeechMicClick,
handleMicFocus,
handleMicBlur,
]);
// Memoize debug UI (only render when DEBUG_MODE is true)
const debugUI = useMemo(() => {
if (!DEBUG_MODE) return null;
return (
<div
style={{
position: 'absolute',
top: 10,
right: 10,
color: '#fff',
zIndex: 10000,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: '10px',
borderRadius: '5px',
fontSize: '14px',
}}
>
<div>Voice Version: {voiceVersion}</div>
<div>Input Mode: {voiceInputMode || 'None'}</div>
<div>Current Mode: {currentMode}</div>
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<>
<div>isListening: {isListening ? '🎤 YES' : '❌ NO'}</div>
<div>Interim: {interimText || 'N/A'}</div>
</>
)}
</div>
);
}, [voiceVersion, voiceInputMode, currentMode, isListening, interimText]);
return (
<TFullPopup
open={isVisible}
@@ -739,29 +857,7 @@ const VoiceInputOverlay = ({
<div className={css.dimBackground} onClick={handleClose} />
{/* 디버깅용: Voice 상태 표시 */}
<div
style={{
position: 'absolute',
top: 10,
right: 10,
color: '#fff',
zIndex: 10000,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: '10px',
borderRadius: '5px',
fontSize: '14px',
}}
>
<div>Voice Version: {voiceVersion}</div>
<div>Input Mode: {voiceInputMode || 'None'}</div>
<div>Current Mode: {currentMode}</div>
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<>
<div>isListening: {isListening ? '🎤 YES' : '❌ NO'}</div>
<div>Interim: {interimText || 'N/A'}</div>
</>
)}
</div>
{debugUI}
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
@@ -786,47 +882,7 @@ const VoiceInputOverlay = ({
/>
{/* voiceVersion에 따라 하나의 마이크만 표시 */}
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micFocused && css.focused
)}
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
)}
{microphoneButton}
{/* VUI_DISABLE_START - VUI 마이크 버튼 비활성화 */}
{/* {voiceVersion === VOICE_VERSION.VUI && (
@@ -875,7 +931,7 @@ const VoiceInputOverlay = ({
</div>
{/* 모드별 컨텐츠 */}
<div className={css.modeContent}>{renderModeContent()}</div>
<div className={css.modeContent}>{renderModeContent}</div>
</OverlayContainer>
</div>
</TFullPopup>

View File

@@ -1,347 +0,0 @@
# `com.webos.service.voiceconductor` v1.1 API 문서
> **생성일**: 2024/04/18
> **최종 수정자**: soonwon.hong
> **문서 생성 도구**: APIEditorScript (`apieditor.script`)
> **대상 플랫폼**: webOS TV
---
## 📌 요약 (Summary)
`voiceconductor`는 webOS TV의 **음성 프레임워크의 핵심 서비스**로, 전체 음성 처리 흐름을 조율(Conduct)합니다.
모든 음성 관련 요청은 이 서비스를 통해 시작되며, 다음 주요 기능을 제공합니다:
- **STT (Speech-to-Text)**: 사용자 음성을 텍스트로 변환
- **Intent 인식**: 음성 또는 텍스트를 기반으로 사용자의 의도(Intent) 분석
---
## 🔍 개요 (Overview)
### API 카테고리
| 카테고리 | 설명 |
|--------|------|
| `/` | 국가별 비즈니스 계약에 따라 **Google, Alexa, Alibaba, LG** 등 다양한 플러그인 중 하나가 자동 선택되어 실행 |
| `/system` | **LG 자사 플러그인**을 강제로 사용 (기본값). `/`와 동일한 API 형식을 가지며, 중복 설명 생략 |
| `/interactor` | **In-App Control** 기능 지원 (webOS 4.5+) — 앱 내에서 음성 명령 처리 가능 |
---
## 🔧 메서드 (Methods)
### `/getRecognitionStatus`
> 현재 진행 중인 음성 인식 작업 상태 조회 (Subscription 지원)
- **지원 플랫폼**: 없음 (`[Private]`)
- **ACG**: 없음
- **파라미터**:
- `taskTypes` (String[]): `["fullVoiceNlp", "voiceNlp", "textNlp", "stt"]`
- `subscribe` (Boolean): 구독 여부
- **반환**:
- `tasks`: 실행 중인 `voiceTask` 객체 배열
- `subscribed`, `returnValue`, `errorCode`, `errorText`
**예시**:
```json
{
"tasks": [
{
"voiceTicket": "V00001",
"taskType": "fullVoiceNlp",
"voiceEngine": "googleAssistant",
"recognitionSource": { "input": "voiceinput", "type": "remote", "key": "mrcu" },
"status": "init"
}
],
"subscribed": true,
"returnValue": true
}
```
---
### `/cancelRecognition`
> 진행 중인 음성 인식 작업 취소
- **지원**: TV (`[Private]`)
- **파라미터**: `voiceTicket` (String)
- **반환**: `returnValue`, `errorCode`, `errorText`
> ⚠️ `stopRecordingVoice`와 달리, 녹음 중이 아닐 경우 STT 플러그인에 종료 요청을 보내지 않음.
---
### `/checkUpdate`
> 특정 패키지에 대한 음성 기능 업데이트 필요 여부 확인
- **지원**: TV (`[Do Not Publish]`)
- **파라미터**:
- `type`: `"package"` (고정)
- `id`: 패키지 ID (예: `"amazon.alexa"`)
- **반환**: `needUpdate` (Boolean)
---
### `/getSupportedLanguages`
> 현재 지원하는 언어 목록 조회
- **지원**: TV (`[Public]`)
- **파라미터**:
- `languageCodes`: BCP-47 언어 코드 배열 (예: `["ko-KR", "en-US"]`)
- `voiceEngine`: `"stt"` (기본) 또는 `"nlp"`
- `showAll`: 엔진별 상세 언어 반환 여부
- `loadDefault`: 서버 없이 기본 언어 반환
- **반환**:
- `voiceLanguages`: `{ "ko-KR": "ko-KR", "xx-XX": "notSupported" }`
- `voiceEngines`: (showAll=true 시)
**성공 응답**:
```json
{
"returnValue": true,
"voiceLanguages": {
"ko-KR": "ko-KR",
"en-US": "en-US",
"xx-XX": "notSupported"
}
}
```
---
### `/getUtteranceEvents`
> **`getVoiceUiEvents`와 동일** — 설명 생략
---
### `/getVoiceKey`
> 서버 통신에 필요한 보안 키 반환
- **반환**: `vsn`, `staticVoiceKey`, `dynamicVoiceKey`
---
### `/getVoiceUiEvents`
> 음성 처리 중 UI 이벤트 수신 (Subscription 필수)
- **지원**: TV (`[Public]`)
- **이벤트 종류**:
- `registered`, `sttStart`, `sttVoiceLevel`, `sttPartialResult`, `sttResult`, `sttEnd`
- `nlpStart`, `nlpEnd`, `actionFeedback`, `sessionEnd`, `error`
**이벤트 예시**:
```json
{ "event": "sttVoiceLevel", "level": 45, "subscribed": true }
{ "event": "actionFeedback", "feedback": { "systemUtterance": "{{1로}} 검색 결과입니다." } }
{ "event": "sessionEnd", "subscribed": false }
```
---
## 🎮 In-App Control (`/interactor`)
### `/interactor/register`
> 앱을 음성 프레임워크에 등록 (Subscription 필수)
- **명령어**:
- `setContext`: 앱이 `setContext` 호출 필요
- `performAction`: 앱이 액션 수행 후 `reportActionResult` 호출
**응답 예시**:
```json
{ "command": "setContext", "voiceTicket": "V00000006" }
{ "command": "performAction", "action": { "intent": "Select", "itemId": "test" } }
```
---
### `/interactor/reportActionResult`
> In-App 액션 처리 결과 보고
- **필수 파라미터**: `voiceTicket`, `result` (Boolean)
- **옵션**: `feedback` (`inAppFeedback`)
---
### `/interactor/setContext`
> 앱이 처리 가능한 Intent 목록 등록
- **파라미터**: `voiceTicket`, `inAppIntents` (배열)
- **Intent 유형**: `Select`, `Scroll`, `PlayContent`, `Zoom` 등 9가지
**예시**:
```json
{
"inAppIntents": [
{
"intent": "Select",
"items": [{ "itemId": "test", "value": ["test"] }]
}
]
}
```
---
## 🎤 음성 인식 API
### `/recognizeIntentByText`
> 텍스트 기반 Intent 분석 및 실행
- **지원**: TV (`[Public]`)
- **파라미터**: `text`, `language`, `runVoiceUi`, `inAppControl`, `source`
- **반환**: `serverResponse` (`responseCode`, `responseMessage`)
---
### `/recognizeIntentByVoice`
> PCM 파일 기반 음성 Intent 분석 (**테스트 전용**)
- **지원**: TV (`[Private]`)
- **필수**: `pcmPath`
---
### `/recognizeVoice`
> 음성을 텍스트로 변환 (STT)
- **반환**: `text` (String 배열)
---
### `/recognizeVoiceWithDetails`
> STT 과정의 상세 이벤트 스트림 (Subscription 필수)
- **이벤트**: `sttVoiceLevel`, `sttPartialResult`, `sttResult`, `sessionEnd`
- **반환**: `event`, `text`, `level`, `feedback`, `voiceTicket`
**응답 예시**:
```json
{ "event": "sttResult", "text": ["배트맨", "베트맨"], "voiceTicket": "V00000007" }
```
---
### `/setInputEvent`
> 하드웨어/소프트웨어 버튼 이벤트 처리
- **inputType**: `"hold"` (down/up), `"wakeword"`
- **deviceId**: `"remote"`, `"amazonAlexa"`
---
### `/recordVoice` & `/stopRecordingVoice`
> 음성 데이터 스트리밍 녹음 시작/종료
- **recordVoice**: WebSocket URL 반환 (`websocketUrl`)
- **stopRecordingVoice**: 녹음 중지
**recordVoice 응답**:
```json
{ "event": "recordingStart", "websocketUrl": "wss://..." }
{ "event": "recordingEnd" }
```
---
## 📦 시스템 API
### `/system/checkUpdate`
> LG 자사 플러그인 기반 업데이트 확인
- **동작**: 필요한 경우 팝업 표시 후 음성 기능 중단
---
## ❌ 오류 코드 참조
| 코드 | 텍스트 | 설명 |
|------|--------|------|
| 100 | `bad params` | 파라미터 오류 |
| 101 | `deprecated api` | 더 이상 지원되지 않음 |
| 300 | `precondition not satisfied` | 네트워크/설정 미완료 |
| 301 | `internal processing error` | 내부 오류 (메모리 등) |
| 302 | `failed recognize voice` | STT 실패 |
| 303 | `failed recognize intent` | NLP 실패 |
| 304 | `unsupported language` | 언어 미지원 |
| 305 | `already processing another request` | 다른 요청 진행 중 |
---
## 🧱 객체 정의 (Objects)
### `actionFeedback`
- `systemUtterance`: 사용자에게 표시할 문구
- `exception`: 예외 유형 (예: `"alreadyCompleted"`)
---
### `serverResponse`
- `responseCode`: 처리 결과 코드
- `responseMessage`: 피드백 메시지
---
### `inAppAction`
- `type`: `"IntentMatch"`
- `intent`: `"Select"`, `"Scroll"`, `"Zoom"`
- `itemId`: 앱 내 고유 ID
- 추가 필드: `scroll`, `checked`, `state`, `control`, `zoom`
---
### `inAppIntent`
- `intent`: 지원 Intent 유형
- `items`: 선택 가능한 항목 (`inAppIntentItem` 배열)
- `itemId`: 스크롤/줌 등 단일 액션용 ID
---
### `inAppIntentItem`
- `itemId`: 고유 ID
- `value`: STT 매칭 문구 배열
- `title`: UI 표시 문구
- `checked` / `state`: 체크박스/토글 상태
---
### `inAppFeedback`
- `voiceUi`: `actionFeedback` 객체
---
### `voiceEngines`
- 엔진별 언어 지원 정보:
```json
{
"thinQtv": { "ko-KR": "ko-KR" },
"googleAssistant": { "ko-KR": "notSupported" }
}
```
---
### `voiceTask`
- `voiceTicket`: 세션 ID
- `status`: `"init"`, `"running"`, `"finished"`
- `taskType`: `"stt"`, `"fullVoiceNlp"` 등
- `voiceEngine`: 사용 중인 엔진 (예: `"googleAssistant"`)
- `recognitionSource`: 입력 소스 정보
---
### `voiceRecognitionSource`
- `input`: `"voiceinput"` 또는 `"text"`
- `type`: `"remote"`, `"mrcu"`, `"amazonEcho"` 등
- `key`: 소스 세부 키 (예: `"mrcu"`)
---
> 📎 **관련 문서**: [LG Collab 문서](http://collab.lge.com/main/pages/viewpage.action?pageId=789627390)
---
✅ 이 문서는 **webOS TV 음성 프레임워크 개발자**를 위한 전체 API 참조입니다.
🔒 `[Private]` 또는 `[Do Not Publish]`로 표시된 API는 외부 공개용이 아닙니다.

View File

@@ -1,604 +0,0 @@
📋 구현 계획
● 이제 각 단계별로 자세히 설명드리겠습니다.
---
🔧 구체적인 구현 방법
1⃣ voiceActions.js에 UseIME Intent 추가
현재 문제: sendVoiceIntents 함수가 Select, Scroll만 등록하고 UseIME는 없음
해결 방법: UseIME Intent를 추가하고, STT 텍스트를 Redux로 dispatch
// src/actions/voiceActions.js - sendVoiceIntents 수정
export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => {
console.log('[Voice] Sending voice intents...');
const inAppIntents = [
// ⭐ UseIME Intent 추가 - 음성 입력 텍스트 받기
{
intent: 'UseIME',
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
},
// 기존 intents...
{
intent: 'Select',
supportOrdinal: true,
items: [
// ... 기존 items
],
},
{
intent: 'Scroll',
supportOrdinal: false,
items: [
// ... 기존 items
],
},
];
// ... 나머지 코드 동일
};
handleVoiceAction 수정 - UseIME 처리 추가:
// src/actions/voiceActions.js
export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => {
console.log('[Voice] Handling voice action:', action);
let result = false;
let feedback = null;
try {
// ⭐ UseIME Intent 처리 추가
if (action.intent === 'UseIME' && action.value) {
console.log('[Voice] STT Text received:', action.value);
// STT 텍스트를 Redux로 dispatch
dispatch({
type: types.VOICE_STT_TEXT_RECEIVED,
payload: action.value,
});
result = true;
feedback = {
voiceUi: {
systemUtterance: `Searching for ${action.value}`,
},
};
}
else if (action.intent === 'Select' && action.itemId) {
result = dispatch(handleSelectIntent(action.itemId));
}
else if (action.intent === 'Scroll' && action.itemId) {
result = dispatch(handleScrollIntent(action.itemId));
}
else {
console.warn('[Voice] Unknown intent:', action);
result = false;
feedback = {
voiceUi: {
systemUtterance: 'This action is not supported',
},
};
}
} catch (error) {
console.error('[Voice] Error processing action:', error);
result = false;
}
dispatch(reportActionResult(voiceTicket, result, feedback));
};
---
2⃣ actionTypes.js에 새 타입 추가
// src/actions/actionTypes.js
export const types = {
// ... 기존 types
// Voice 관련
VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS',
VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE',
VOICE_SET_TICKET: 'VOICE_SET_TICKET',
VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS',
VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE',
VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS',
VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION',
VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS',
VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE',
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',
// ⭐ 새로 추가
VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED',
};
---
3⃣ voiceReducer.js 수정
// src/reducers/voiceReducer.js
const initialState = {
// ... 기존 state
// ⭐ STT 텍스트 state 추가
lastSTTText: null,
sttTimestamp: null,
};
export const voiceReducer = (state = initialState, action) => {
switch (action.type) {
// ... 기존 cases
// ⭐ STT 텍스트 수신 처리
case types.VOICE_STT_TEXT_RECEIVED:
return {
...state,
lastSTTText: action.payload,
sttTimestamp: new Date().toISOString(),
};
case types.VOICE_CLEAR_STATE:
return {
...initialState,
};
default:
return state;
}
};
---
4⃣ SearchPanel에서 VUI 통합
방법 1: Custom Hook 생성 (권장)
// src/hooks/useSearchVoice.js
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { registerVoiceFramework, unregisterVoiceFramework } from '../actions/voiceActions';
/**
* SearchPanel용 음성 입력 Hook
* - SearchPanel이 foreground일 때 voice framework 등록
* - STT 텍스트를 자동으로 searchQuery로 설정
*/
export const useSearchVoice = (isOnTop, onSTTText) => {
const dispatch = useDispatch();
const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// SearchPanel이 foreground일 때만 voice 등록
useEffect(() => {
if (isOnTop) {
console.log('[useSearchVoice] Registering voice framework');
dispatch(registerVoiceFramework());
} else {
console.log('[useSearchVoice] Unregistering voice framework');
dispatch(unregisterVoiceFramework());
}
// Cleanup on unmount
return () => {
dispatch(unregisterVoiceFramework());
};
}, [isOnTop, dispatch]);
// STT 텍스트 수신 처리
useEffect(() => {
if (lastSTTText && sttTimestamp) {
console.log('[useSearchVoice] STT text received:', lastSTTText);
onSTTText && onSTTText(lastSTTText);
}
}, [lastSTTText, sttTimestamp, onSTTText]);
};
SearchPanel.jsx 수정:
// src/views/SearchPanel/SearchPanel.jsx
import React, { useCallback, useState } from 'react';
import { useSearchVoice } from '../../hooks/useSearchVoice';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal || '');
// ⭐ Voice Overlay 상태 관리
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT);
// ⭐ STT 텍스트 수신 핸들러
const handleSTTText = useCallback((sttText) => {
console.log('[SearchPanel] STT text received:', sttText);
// 1. searchQuery 업데이트
setSearchQuery(sttText);
// 2. 자동 검색 실행
if (sttText && sttText.trim()) {
dispatch(
getSearch({
service: 'com.lgshop.app',
query: sttText.trim(),
domain: 'theme,show,item',
})
);
}
// 3. Voice Overlay 닫기 (선택사항)
setIsVoiceOverlayVisible(false);
}, [dispatch]);
// ⭐ Voice Hook 활성화
useSearchVoice(isOnTop, handleSTTText);
// 마이크 버튼 클릭 핸들러
const handleMicButtonClick = useCallback(() => {
console.log('[SearchPanel] Mic button clicked');
setVoiceMode(VOICE_MODES.PROMPT);
setIsVoiceOverlayVisible(true);
}, []);
const handleVoiceOverlayClose = useCallback(() => {
console.log('[SearchPanel] Voice overlay closed');
setIsVoiceOverlayVisible(false);
}, []);
return (
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody}>
{/* 기존 SearchPanel UI */}
<TInput
value={searchQuery}
onChange={handleSearchChange}
onIconClick={() => handleSearchSubmit(searchQuery)}
// ... props
/>
{/* ⭐ 마이크 버튼 추가 (선택사항) */}
<button onClick={handleMicButtonClick}>
🎤 Voice Search
</button>
{/* ⭐ Voice Overlay */}
<VoiceInputOverlay
isVisible={isVoiceOverlayVisible}
onClose={handleVoiceOverlayClose}
mode={voiceMode}
searchQuery={searchQuery}
onSearchChange={(e) => setSearchQuery(e.value)}
onSearchSubmit={handleSearchSubmit}
suggestions={paginatedKeywords?.map(k => k.keyword) || []}
/>
{/* 검색 결과 등 나머지 UI */}
</TBody>
</TPanel>
);
}
---
5⃣ VoiceInputOverlay 개선
마이크 버튼 클릭 시 실제 음성 입력 시작:
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import { useSelector } from 'react-redux';
const VoiceInputOverlay = ({
isVisible,
onClose,
mode,
suggestions,
searchQuery,
onSearchChange,
onSearchSubmit,
}) => {
const [currentMode, setCurrentMode] = useState(mode);
// ⭐ Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// ⭐ STT 텍스트 수신 시 listening 모드로 전환
useEffect(() => {
if (lastSTTText && isVisible) {
console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
if (onSearchChange) {
onSearchChange({ value: lastSTTText });
}
// listening 모드로 전환 (시각적 피드백)
setCurrentMode(VOICE_MODES.LISTENING);
// 1초 후 자동 닫기 (선택사항)
setTimeout(() => {
onClose();
}, 1000);
}
}, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// 마이크 버튼 클릭 핸들러
const handleMicClick = useCallback((e) => {
e?.stopPropagation();
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING) {
// listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from LISTENING mode');
onClose();
}
}, [currentMode, onClose]);
return (
<TFullPopup open={isVisible} onClose={onClose}>
{/* 기존 UI */}
{/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */}
{__DEV__ && (
<div style={{ position: 'absolute', top: 10, right: 10, color: '#fff' }}>
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
</div>
)}
{/* 나머지 UI */}
</TFullPopup>
);
};
---
6⃣ Global Voice Search (handleRelaunch) 구현
App.js 수정:
// src/App/App.js
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { pushPanel } from '../actions/panelActions';
import { panel_names } from '../utils/Config';
function App() {
const dispatch = useDispatch();
// ⭐ webOSRelaunch 이벤트 핸들러
const handleRelaunchEvent = useCallback(() => {
console.log('[App] handleRelaunchEvent triggered');
const launchParams = getLaunchParams();
clearLaunchParams();
if (!launchParams) return;
// ========================================
// ⭐ Voice Intent 처리 (최우선)
// ========================================
if (launchParams.intent) {
const { intent, intentParam, languageCode } = launchParams;
console.log('[App] Voice Intent received:', { intent, intentParam, languageCode });
// SearchContent 또는 PlayContent intent 처리
if (intent === 'SearchContent' || intent === 'PlayContent') {
dispatch(
pushPanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
voiceSearch: true, // 음성 검색 플래그
searchVal: intentParam, // 검색어
languageCode: languageCode,
},
})
);
console.log(`[App] Opening SearchPanel with voice query: ${intentParam}`);
return;
}
}
// ========================================
// 기존 deeplink 처리
// ========================================
if (launchParams.contentTarget) {
console.log('[App] DeepLink:', launchParams.contentTarget);
// dispatch(handleDeepLink(launchParams.contentTarget));
}
}, [dispatch]);
// ⭐ webOSRelaunch 이벤트 리스너 등록
useEffect(() => {
document.addEventListener('webOSRelaunch', handleRelaunchEvent);
return () => {
document.removeEventListener('webOSRelaunch', handleRelaunchEvent);
};
}, [handleRelaunchEvent]);
return (
<div className="app">
{/* 앱 컴포넌트 */}
</div>
);
}
// ⭐ Launch 파라미터 헬퍼 함수
function getLaunchParams() {
if (window.PalmSystem) {
try {
const params = JSON.parse(window.PalmSystem.launchParams || '{}');
console.log('[App] Launch params:', params);
return params;
} catch (e) {
console.error('[App] Failed to parse launch params:', e);
return null;
}
}
return null;
}
function clearLaunchParams() {
if (window.PalmSystem) {
window.PalmSystem.launchParams = '{}';
}
}
export default App;
---
🎯 전체 흐름 요약
Foreground Voice Input (VoiceInputOverlay)
1. SearchPanel이 foreground가 됨 (isOnTop = true)
2. useSearchVoice Hook이 registerVoiceFramework() dispatch
3. voiceActions.js에서 luna://com.webos.service.voiceconductor/interactor/register 호출
4. voiceTicket 수신
5. setContext command 수신 → sendVoiceIntents() 자동 호출
6. UseIME Intent 등록 완료
7. 사용자가 마이크 버튼 클릭 → VoiceInputOverlay 표시
8. 사용자가 음성 발화 (예: "iPhone")
9. performAction command 수신:
{
command: "performAction",
action: { intent: "UseIME", value: "iPhone" }
}
10. handleVoiceAction()에서 VOICE_STT_TEXT_RECEIVED dispatch
11. useSearchVoice의 useEffect가 lastSTTText 감지
12. onSTTText('iPhone') 호출
13. SearchPanel의 handleSTTText에서:
- setSearchQuery('iPhone')
- getSearch() dispatch
- VoiceInputOverlay 닫기
Global Voice Search
1. 사용자가 리모컨 음성 버튼으로 "Search for iPhone on Shop Time" 발화
2. webOS가 앱 실행하면서 webOSRelaunch 이벤트 발생
3. App.js의 handleRelaunchEvent에서:
launchParams = {
intent: "SearchContent",
intentParam: "iPhone",
languageCode: "en-US"
}
4. SearchPanel을 pushPanel하면서 panelInfo에 전달:
{
voiceSearch: true,
searchVal: "iPhone",
languageCode: "en-US"
}
5. SearchPanel이 마운트되면서 panelInfo.voiceSearch 감지
6. 자동으로 getSearch('iPhone') dispatch
---
📝 체크리스트
각 단계가 완료되었는지 확인해보세요:
● ---
🔑 핵심 포인트
1. UseIME이 STT 텍스트를 받는 핵심
// Intent 등록
{
intent: "UseIME",
supportAsrOnly: true // 순수 STT만 (NLP 불필요)
}
// performAction 수신
{
action: {
intent: "UseIME",
value: "사용자가 말한 텍스트" // ⭐ 이게 핵심!
}
}
2. Voice 등록은 Foreground 패널에서만
- SearchPanel이 isOnTop = true일 때만 registerVoiceFramework() 호출
- 다른 패널로 이동하면 unregisterVoiceFramework() 호출
- 이미 VoicePanel에서 기본 구조는 구현되어 있으므로, 이를 SearchPanel에 복사/적용하면 됨
3. VoiceInputOverlay는 UI만 담당
- VoiceInputOverlay는 시각적 피드백(prompt/listening)만 표시
- 실제 voice framework 등록은 SearchPanel에서 담당
- Redux의 lastSTTText 변경을 감지해서 UI 업데이트
4. Redux를 통한 상태 공유
registerVoiceFramework()
voiceTicket 수신 → Redux에 저장
performAction (UseIME) 수신 → Redux에 lastSTTText 저장
SearchPanel의 useEffect가 lastSTTText 감지
handleSTTText() 호출 → 검색 실행
---
🚀 다음 단계
1. 먼저 voiceActions.js 수정 - UseIME Intent 추가 (가장 중요!)
2. actionTypes 및 reducer 업데이트 - STT 텍스트 state 관리
3. useSearchVoice Hook 구현 - 재사용 가능한 hook
4. SearchPanel 통합 - useSearchVoice 사용
5. VoiceInputOverlay 개선 - Redux 연결
6. App.js handleRelaunch - Global Voice Search
7. 테스트 - VoicePanel에서 먼저 테스트 후 SearchPanel 적용
---
💡 디버깅 팁
Voice 등록 확인
// VoicePanel에서 "Register" 버튼 클릭
// Logs에서 확인:
// 1. REQUEST: Register Voice Framework
// 2. RESPONSE: { returnValue: true, subscribed: true }
// 3. COMMAND: setContext Command Received (voiceTicket 있음)
// 4. REQUEST: Set Voice Context (UseIME intent 확인)
// 5. RESPONSE: Set Voice Context Success
STT 텍스트 수신 확인
// 마이크 버튼 클릭 후 발화하면:
// Logs에서 확인:
// 1. COMMAND: performAction Command Received
// 2. action: { intent: "UseIME", value: "발화 텍스트" }
// 3. Redux devtools에서 VOICE_STT_TEXT_RECEIVED 액션 확인
---

View File

@@ -1,881 +0,0 @@
# [251015] React 프로젝트 VUI 구현 완벽 가이드
## 📚 목차
- [개요](#개요)
- [1. VUI 핵심 개념](#1-vui-핵심-개념)
- [2. voiceTicket과 STT 텍스트 수신 원리](#2-voiceticket과-stt-텍스트-수신-원리)
- [3. handleRelaunch 구현](#3-handlerelaunch-구현)
- [4. Luna Service 래퍼 구현](#4-luna-service-래퍼-구현)
- [5. Custom Hook 구현](#5-custom-hook-구현)
- [6. React 컴포넌트 통합](#6-react-컴포넌트-통합)
- [7. 전체 구현 체크리스트](#7-전체-구현-체크리스트)
- [8. 트러블슈팅](#8-트러블슈팅)
---
## 개요
이 문서는 React 기반 webOS TV 앱에서 **Voice User Interface (VUI)** 기능을 구현하는 완벽한 가이드입니다.
### 문서 목적
- ✅ voiceTicket의 정확한 역할 이해
- ✅ STT(Speech-to-Text) 텍스트를 받는 방법 명확히 설명
- ✅ handleRelaunch 구현 예시 제공
- ✅ 전체 React 프로젝트 구조에서의 구현 방법 안내
### 지원 환경
- **webOS 버전**: 5.0 MR2 이상 (2020년형 TV 이후)
- **React 버전**: 16.8+ (Hooks 지원)
- **Luna Service**: `com.webos.service.voiceconductor`
---
## 1. VUI 핵심 개념
### 1.1 VUI 기능 분류
webOS VUI는 크게 2가지 유형으로 나뉩니다:
#### A. **Global Voice Search (전역 음성 검색)**
- 앱이 **백그라운드/종료 상태**에서도 동작
- 사용자: "Search for iPhone on Shop Time"
- 시스템이 앱을 실행하고 `intent`, `intentParam` 전달
- **handleRelaunch**를 통해 파라미터 수신
#### B. **Foreground Voice Input (포그라운드 음성 입력)**
- 앱이 **포그라운드**일 때만 동작
- 사용자가 🎤 버튼을 누르고 발화
- **VoiceConductor Service**와 직접 통신
- STT 텍스트를 실시간으로 수신
---
## 2. voiceTicket과 STT 텍스트 수신 원리
### 2.1 voiceTicket이란?
**voiceTicket**은 Voice Framework와 통신하기 위한 **인증 토큰**입니다.
```
[앱] ---register---> [Voice Framework]
<--- voiceTicket 발급 ---
이후 모든 API 호출 시 voiceTicket 필요:
- setContext (Intent 등록)
- reportActionResult (결과 보고)
```
### 2.2 STT 텍스트 수신 메커니즘 ⭐
**핵심 원리**: `/interactor/register``subscribe: true`로 호출하면, **음성 명령이 있을 때마다 같은 콜백이 계속 호출됩니다!**
```javascript
// ❌ 잘못된 이해: voiceTicket으로 텍스트를 조회한다?
// ⭐ 올바른 이해: subscribe 모드의 onSuccess가 계속 호출되며 텍스트가 전달된다!
webOS.service.request('luna://com.webos.service.voiceconductor', {
method: '/interactor/register',
parameters: {
type: 'foreground',
subscribe: true // ⚠️ 이것이 핵심!
},
onSuccess: function(response) {
// 최초 1회: voiceTicket 발급
if (response.voiceTicket) {
voiceTicket = response.voiceTicket;
}
// 사용자가 발화할 때마다 이 콜백이 다시 호출됨!
if (response.command === 'performAction') {
// ✅ 여기서 STT 텍스트 수신!
console.log('STT 텍스트:', response.action.value);
console.log('Intent:', response.action.intent);
}
}
});
```
### 2.3 전체 흐름도
```
┌─────────────────────────────────────────────────────────────┐
│ 1. /interactor/register (subscribe: true) │
│ → onSuccess에서 voiceTicket 저장 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. /interactor/setContext │
│ → voiceTicket + UseIME intent 등록 │
│ → Voice Framework에 "음성 입력 받을 준비 됨" 알림 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. 사용자가 리모컨 🎤 버튼 누르고 발화 │
│ 예: "iPhone" │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. /interactor/register의 onSuccess가 다시 호출됨! ✅ │
│ response.command === "performAction" │
│ response.action.intent === "UseIME" │
│ response.action.value === "iPhone" (STT 텍스트!) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. /interactor/reportActionResult │
│ → Voice Framework에 처리 완료 보고 │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. handleRelaunch 구현
### 3.1 Global Voice Search 파라미터 수신
`appinfo.json` 설정:
```json
{
"id": "com.lgshop.app",
"version": "2.0.0",
"inAppVoiceIntent": {
"contentTarget": {
"intent": "$INTENT",
"intentParam": "$INTENT_PARAM",
"languageCode": "$LANG_CODE"
},
"voiceConfig": {
"supportedIntent": ["SearchContent", "PlayContent"],
"supportedVoiceLanguage": ["en-US", "ko-KR"]
}
}
}
```
### 3.2 App.js에서 handleRelaunch 구현
```javascript
// src/App/App.js
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { pushPanel } from '../actions/panelActions';
import { panel_names } from '../utils/Config';
function App() {
const dispatch = useDispatch();
// ✅ handleRelaunchEvent 구현
const handleRelaunchEvent = useCallback(() => {
console.log("handleRelaunchEvent started");
const launchParams = getLaunchParams();
clearLaunchParams();
// ========================================
// ✅ Voice Intent 처리 (최우선)
// ========================================
if (launchParams?.intent) {
const { intent, intentParam, languageCode } = launchParams;
console.log("[Voice Intent]", { intent, intentParam, languageCode });
// SearchContent 또는 PlayContent intent 처리
if (intent === "SearchContent" || intent === "PlayContent") {
dispatch(
pushPanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
voiceSearch: true, // 음성 검색 플래그
searchVal: intentParam, // 검색어
languageCode: languageCode,
},
})
);
// 로깅 (선택사항)
console.log(`[VUI] Opening SearchPanel with query: ${intentParam}`);
return;
}
}
// ========================================
// 기존 deeplink 처리
// ========================================
if (launchParams?.contentTarget) {
console.log("[DeepLink]", launchParams.contentTarget);
dispatch(handleDeepLink(launchParams.contentTarget));
}
}, [dispatch]);
// ✅ webOSRelaunch 이벤트 리스너 등록
useEffect(() => {
document.addEventListener("webOSRelaunch", handleRelaunchEvent);
// Cleanup
return () => {
document.removeEventListener("webOSRelaunch", handleRelaunchEvent);
};
}, [handleRelaunchEvent]);
return (
<div className="app">
{/* 앱 컴포넌트 */}
</div>
);
}
// ✅ Launch 파라미터 헬퍼 함수
function getLaunchParams() {
// PalmSystem에서 launch params 추출
if (window.PalmSystem) {
const params = JSON.parse(window.PalmSystem.launchParams || '{}');
return params;
}
return null;
}
function clearLaunchParams() {
if (window.PalmSystem) {
window.PalmSystem.launchParams = '{}';
}
}
export default App;
```
### 3.3 SearchPanel에서 voiceSearch 플래그 처리
```javascript
// src/views/SearchPanel/SearchPanel.jsx
import React, { useEffect } from 'react';
function SearchPanel({ panelInfo, isOnTop }) {
// ✅ 음성 검색으로 패널이 열렸을 때 자동 검색
useEffect(() => {
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
console.log("[SearchPanel] Voice search triggered:", panelInfo.searchVal);
// 자동으로 검색 실행
dispatch(
getSearch({
service: "com.lgshop.app",
query: panelInfo.searchVal,
domain: "theme,show,item",
})
);
}
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
return (
<div>
{/* SearchPanel UI */}
</div>
);
}
export default SearchPanel;
```
---
## 4. Luna Service 래퍼 구현
### 4.1 voiceconductor.js 생성
```javascript
// src/lunaSend/voiceconductor.js
import LS2Request from "./LS2Request";
/**
* VoiceConductor 서비스: Foreground 앱의 음성 명령 처리
*/
// 현재 활성화된 voiceTicket
let currentVoiceTicket = null;
let voiceHandlerRef = null;
/**
* Voice Framework에 Foreground 앱으로 등록
*
* @param {Function} onCommand - performAction 수신 시 호출되는 콜백
* @param {Function} onSuccess - 등록 성공 시 콜백
* @param {Function} onFailure - 등록 실패 시 콜백
* @returns {Object} LS2Request 인스턴스
*/
export const registerVoiceConductor = ({ onCommand, onSuccess, onFailure }) => {
// ========================================
// Mock 처리 (브라우저 환경)
// ========================================
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK registerVoiceConductor");
const mockTicket = "mock-voice-ticket-" + Date.now();
currentVoiceTicket = mockTicket;
onSuccess && onSuccess({ returnValue: true, voiceTicket: mockTicket });
return null;
}
// ========================================
// 기존 구독 취소
// ========================================
if (voiceHandlerRef) {
voiceHandlerRef.cancel();
voiceHandlerRef = null;
}
// ========================================
// Voice Framework 등록 (subscribe: true!)
// ========================================
voiceHandlerRef = new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/register",
subscribe: true, // ⚠️ 핵심!
parameters: {
type: "foreground",
},
onSuccess: (res) => {
console.log("[VoiceConductor] register response:", res);
// ✅ 최초 등록 성공: voiceTicket 저장
if (res.voiceTicket) {
currentVoiceTicket = res.voiceTicket;
console.log("[VoiceConductor] voiceTicket issued:", currentVoiceTicket);
}
// ✅ performAction 수신 처리 (STT 텍스트!)
if (res.command === "performAction") {
console.log("[VoiceConductor] performAction received:", res.action);
onCommand && onCommand(res.action, res.voiceTicket);
}
// 최초 등록 성공 콜백
if (res.returnValue && res.voiceTicket && !res.command) {
onSuccess && onSuccess(res);
}
},
onFailure: (err) => {
console.error("[VoiceConductor] register failed:", err);
currentVoiceTicket = null;
onFailure && onFailure(err);
},
});
return voiceHandlerRef;
};
/**
* Voice Framework에 현재 화면에서 처리 가능한 Intent 등록
*
* @param {Array} inAppIntents - Intent 배열
* @param {Object} callbacks - onSuccess, onFailure 콜백
* @returns {Object} LS2Request 인스턴스
*/
export const setVoiceContext = (inAppIntents, { onSuccess, onFailure }) => {
// Mock 처리
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK setVoiceContext:", inAppIntents);
onSuccess && onSuccess({ returnValue: true });
return null;
}
// voiceTicket 검증
if (!currentVoiceTicket) {
console.warn("[VoiceConductor] No voiceTicket. Call registerVoiceConductor first.");
onFailure && onFailure({ returnValue: false, errorText: "No voiceTicket" });
return null;
}
return new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/setContext",
parameters: {
voiceTicket: currentVoiceTicket,
inAppIntents: inAppIntents,
},
onSuccess: (res) => {
console.log("[VoiceConductor] setContext success:", res);
onSuccess && onSuccess(res);
},
onFailure: (err) => {
console.error("[VoiceConductor] setContext failed:", err);
onFailure && onFailure(err);
},
});
};
/**
* Voice 명령 처리 결과를 Voice Framework에 보고
*
* @param {Boolean} result - 성공 여부
* @param {String} utterance - TTS로 읽을 피드백 메시지
* @param {String} exception - 에러 타입
* @returns {Object} LS2Request 인스턴스
*/
export const reportActionResult = ({ result, utterance, exception, onSuccess, onFailure }) => {
// Mock 처리
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK reportActionResult:", { result, utterance, exception });
onSuccess && onSuccess({ returnValue: true });
return null;
}
if (!currentVoiceTicket) {
console.warn("[VoiceConductor] No voiceTicket for reportActionResult.");
return null;
}
const feedback = {};
if (utterance || exception) {
feedback.voiceUi = {};
if (utterance) feedback.voiceUi.systemUtterance = utterance;
if (exception) feedback.voiceUi.exception = exception;
}
return new LS2Request().send({
service: "luna://com.webos.service.voiceconductor",
method: "/interactor/reportActionResult",
parameters: {
voiceTicket: currentVoiceTicket,
result: result,
...(Object.keys(feedback).length > 0 && { feedback }),
},
onSuccess: (res) => {
console.log("[VoiceConductor] reportActionResult success:", res);
onSuccess && onSuccess(res);
},
onFailure: (err) => {
console.error("[VoiceConductor] reportActionResult failed:", err);
onFailure && onFailure(err);
},
});
};
/**
* Voice Framework 등록 해제
*/
export const unregisterVoiceConductor = () => {
if (voiceHandlerRef) {
console.log("[VoiceConductor] unregister");
voiceHandlerRef.cancel();
voiceHandlerRef = null;
}
currentVoiceTicket = null;
};
/**
* 현재 voiceTicket 가져오기
*/
export const getVoiceTicket = () => currentVoiceTicket;
```
### 4.2 lunaSend/index.js에 export 추가
```javascript
// src/lunaSend/index.js
import { LS2RequestSingleton } from './LS2RequestSingleton';
export * from './account';
export * from './common';
export * from './voiceconductor'; // ✅ 추가
export const cancelReq = (instanceName) => {
let r = LS2RequestSingleton.instance(instanceName);
if (r) {
r.cancel();
r.cancelled = false;
LS2RequestSingleton.deleteInstance(instanceName);
}
};
```
---
## 5. Custom Hook 구현
### 5.1 useVoiceConductor.js 생성
```javascript
// src/hooks/useVoiceConductor.js
import { useEffect, useCallback, useRef } from "react";
import {
registerVoiceConductor,
setVoiceContext,
reportActionResult,
unregisterVoiceConductor,
} from "../lunaSend/voiceconductor";
/**
* VoiceConductor Hook: 음성 명령 처리를 위한 React Hook
*
* @param {Boolean} isActive - 패널이 활성화(foreground)되었는지 여부
* @param {Function} onVoiceInput - STT 텍스트 수신 시 호출되는 콜백
* @param {Array} inAppIntents - 등록할 intent 목록 (선택, 기본값: UseIME)
*
* @example
* // SearchPanel에서 사용
* useVoiceConductor(isOnTop, (text) => {
* console.log('음성 텍스트:', text);
* setSearchQuery(text);
* handleSearchSubmit(text);
* });
*/
export const useVoiceConductor = (isActive, onVoiceInput, inAppIntents = null) => {
const isActiveRef = useRef(isActive);
// isActive 상태를 ref로 관리 (콜백에서 최신 값 참조)
useEffect(() => {
isActiveRef.current = isActive;
}, [isActive]);
// ========================================
// performAction 수신 처리
// ========================================
const handleCommand = useCallback(
(action, voiceTicket) => {
// 패널이 활성화되지 않았으면 무시
if (!isActiveRef.current) {
console.log("[useVoiceConductor] Not active, ignoring command");
return;
}
console.log("[useVoiceConductor] handleCommand:", action);
const { intent, value } = action;
// ✅ UseIME: 음성 입력 텍스트 전달
if (intent === "UseIME" && value) {
console.log("[useVoiceConductor] STT 텍스트:", value);
onVoiceInput && onVoiceInput(value);
// 성공 피드백 보고
reportActionResult({
result: true,
utterance: `Searching for ${value}`,
});
}
// 다른 intent 처리 가능 (Select, Scroll 등)
// if (intent === "Select" && action.itemId) { ... }
},
[onVoiceInput]
);
// ========================================
// Voice Framework 등록 및 Intent 설정
// ========================================
useEffect(() => {
if (!isActive) return;
console.log("[useVoiceConductor] Registering VoiceConductor...");
// 1. Voice Framework 등록
const handler = registerVoiceConductor({
onCommand: handleCommand,
onSuccess: (res) => {
console.log("[useVoiceConductor] Registered, voiceTicket:", res.voiceTicket);
// 2. Intent 등록 (UseIME)
const defaultIntents = [
{
intent: "UseIME",
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
},
];
const intentsToRegister = inAppIntents || defaultIntents;
setVoiceContext(intentsToRegister, {
onSuccess: (contextRes) => {
console.log("[useVoiceConductor] Context set successfully");
},
onFailure: (err) => {
console.error("[useVoiceConductor] Failed to set context:", err);
},
});
},
onFailure: (err) => {
console.error("[useVoiceConductor] Registration failed:", err);
},
});
// 3. Cleanup: 패널이 닫히거나 비활성화될 때
return () => {
console.log("[useVoiceConductor] Unregistering...");
unregisterVoiceConductor();
};
}, [isActive, handleCommand, inAppIntents]);
};
```
---
## 6. React 컴포넌트 통합
### 6.1 SearchPanel.jsx에서 사용
```javascript
// src/views/SearchPanel/SearchPanel.jsx
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getSearch, resetSearch } from "../../actions/searchActions";
import { useVoiceConductor } from "../../hooks/useVoiceConductor";
import TInput from "../../components/TInput/TInput";
function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState(panelInfo?.searchVal || "");
// ========================================
// ✅ Voice Input 처리 콜백
// ========================================
const handleVoiceInput = useCallback(
(voiceText) => {
console.log("[SearchPanel] Voice input received:", voiceText);
// 검색어 설정
setSearchQuery(voiceText);
// 즉시 검색 수행
if (voiceText && voiceText.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
query: voiceText.trim(),
domain: "theme,show,item",
})
);
}
},
[dispatch]
);
// ========================================
// ✅ VoiceConductor Hook 활성화
// ========================================
useVoiceConductor(isOnTop, handleVoiceInput);
// ========================================
// ✅ Global Voice Search 처리
// ========================================
useEffect(() => {
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
console.log("[SearchPanel] Global voice search:", panelInfo.searchVal);
setSearchQuery(panelInfo.searchVal);
dispatch(
getSearch({
service: "com.lgshop.app",
query: panelInfo.searchVal,
domain: "theme,show,item",
})
);
}
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
// ========================================
// 수동 검색 처리
// ========================================
const handleSearchSubmit = useCallback(
(query) => {
if (query && query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
query: query.trim(),
domain: "theme,show,item",
})
);
}
},
[dispatch]
);
return (
<div className="search-panel">
<TInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.value)}
onIconClick={() => handleSearchSubmit(searchQuery)}
placeholder="Say or type to search..." // 음성 입력 안내
/>
{/* 검색 결과 표시 */}
</div>
);
}
export default SearchPanel;
```
---
## 7. 전체 구현 체크리스트
### Phase 1: Global Voice Search
- [ ] **appinfo.json 수정**
- [ ] `inAppVoiceIntent` 섹션 추가
- [ ] `supportedIntent``SearchContent`, `PlayContent` 추가
- [ ] `supportedVoiceLanguage` 설정
- [ ] **App.js 수정**
- [ ] `handleRelaunchEvent` 함수 구현
- [ ] `webOSRelaunch` 이벤트 리스너 등록
- [ ] Voice Intent 파라미터 파싱
- [ ] SearchPanel 열기 및 검색 실행
- [ ] **SearchPanel 수정**
- [ ] `panelInfo.voiceSearch` 플래그 확인
- [ ] 자동 검색 로직 추가
### Phase 2: Foreground Voice Input
- [ ] **Luna Service 래퍼 생성**
- [ ] `src/lunaSend/voiceconductor.js` 생성
- [ ] `registerVoiceConductor` 함수 구현
- [ ] `setVoiceContext` 함수 구현
- [ ] `reportActionResult` 함수 구현
- [ ] `unregisterVoiceConductor` 함수 구현
- [ ] **lunaSend/index.js 수정**
- [ ] voiceconductor export 추가
- [ ] **Custom Hook 생성**
- [ ] `src/hooks/useVoiceConductor.js` 생성
- [ ] subscribe 모드 콜백 처리
- [ ] Intent 등록 로직
- [ ] Cleanup 로직
- [ ] **SearchPanel 통합**
- [ ] `useVoiceConductor` Hook 추가
- [ ] `handleVoiceInput` 콜백 구현
- [ ] STT 텍스트로 자동 검색
### Phase 3: 테스트
- [ ] **브라우저 Mock 테스트**
- [ ] Console에 MOCK 로그 확인
- [ ] 수동 트리거로 동작 확인
- [ ] **Emulator 테스트**
- [ ] 앱 빌드 및 설치 (`npm run pack`)
- [ ] Global Voice Search 테스트
- [ ] Foreground Voice Input 테스트
- [ ] **실제 TV 테스트**
- [ ] Voice Remote로 명령 테스트
- [ ] 다국어 테스트 (en-US, ko-KR)
- [ ] Luna Service 로그 확인
---
## 8. 트러블슈팅
### 문제 1: voiceTicket이 null로 나옴
**원인**:
- `subscribe: true`를 설정하지 않음
- Luna Service 호출 실패
**해결**:
```javascript
// ✅ subscribe: true 확인
registerVoiceConductor({
subscribe: true, // 필수!
...
});
```
### 문제 2: STT 텍스트를 받지 못함
**원인**:
- `setContext`에서 UseIME intent를 등록하지 않음
- `performAction` 이벤트 처리 누락
**해결**:
```javascript
// 1. Intent 등록 확인
setVoiceContext([{
intent: "UseIME",
supportAsrOnly: true,
}]);
// 2. performAction 처리 확인
if (response.command === "performAction") {
console.log(response.action.value); // STT 텍스트
}
```
### 문제 3: 패널이 닫혀도 음성 입력이 계속 처리됨
**원인**:
- `unregisterVoiceConductor` 호출 누락
**해결**:
```javascript
// useEffect cleanup에서 반드시 호출
useEffect(() => {
// ... 등록 로직
return () => {
unregisterVoiceConductor(); // ✅ 필수!
};
}, [isActive]);
```
### 문제 4: 브라우저에서 에러 발생
**원인**:
- PalmSystem이 없는 환경에서 Luna Service 호출
**해결**:
```javascript
// Mock 처리 추가
if (typeof window === "object" && !window.PalmSystem) {
console.log("[VoiceConductor] MOCK mode");
// Mock 응답 반환
return;
}
```
---
## 9. 핵심 요약
### voiceTicket의 역할
- Voice Framework와 통신하기 위한 **인증 토큰**
- `/interactor/register` 최초 호출 시 발급
- 이후 `setContext`, `reportActionResult`에 필수
### STT 텍스트 수신 방법
1. `/interactor/register`를 **subscribe: true**로 호출
2. 최초 onSuccess에서 voiceTicket 저장
3. 사용자 발화 시 **같은 onSuccess가 다시 호출**됨
4. `response.command === "performAction"` 확인
5. `response.action.value`에 STT 텍스트 포함!
### 전체 흐름
```
register (subscribe: true)
→ voiceTicket 발급
→ setContext (UseIME 등록)
→ 사용자 발화
→ onSuccess 다시 호출 (performAction)
→ action.value = STT 텍스트!
→ reportActionResult
```
---
## 10. 참고 문서
- **VUI 기본 가이드**: `docs/vui/VoiceUserInterface_ko.md`
- **SearchPanel VUI 구현**: `docs/vui/searchpanel-vui-implementation-guide.md`
- **테스트 시나리오**: `docs/vui/vui-test-scenarios.md`
- **Luna Service 가이드**: webOS Developer Portal
---
**작성일**: 2025-10-15
**작성자**: AI Assistant
**버전**: 1.0.0
**관련 이슈**: React VUI Implementation Guide

View File

@@ -1,309 +0,0 @@
1-1 Can you recommend a 4K TV with Dolby Atmos support under $1,500?
오버레이 인식 : 확인
STT Text : Can you recommend a 4K TV with Dolby Atmos support under $1,500?
Event Logs :
RESPONSE :
{
"subscribed": true,
"command": "setContext",
"returnValue": true,
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
COMMAND :
{
"command": "setContext",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
REQUEST :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
RESPONSE :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
ACTION :
{
"message": "Context set successfully. Press the MIC button on remote and speak.",
"nextStep": "Waiting for performAction event...",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
그러나 두번째 시도에서
STT Text : No STT text received yet. Speak after registering to see the result.
RESPONSE :
{
"subscribed": true,
"command": "setContext",
"returnValue": true,
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
COMMAND :
{
"command": "setContext",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
REQUEST :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
RESPONSE :
{
"returnValue": true
}
ACTION :
{
"message": "Context set successfully. Press the MIC button on remote and speak.",
"nextStep": "Waiting for performAction event...",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
ERROR :
{
"message": "performAction event was not received within 15 seconds after setContext.",
"possibleReasons": [
"1. Did you press the MIC button on the remote control?",
"2. Did you speak after pressing the MIC button?",
"3. UseIME intent might not be supported on this webOS version",
"4. Voice framework might not be routing events correctly"
],
"suggestion": "Try pressing the remote MIC button and speaking clearly. Check VoicePanel logs for performAction event."
}

View File

@@ -1,531 +0,0 @@
# webOS Voice User Interface (VUI) Guide
## Table of Contents
- [webOS Voice User Interface (VUI) Guide](#webos-voice-user-interface-vui-guide)
- [Table of Contents](#table-of-contents)
- [1. Search and Play Commands (Global Actions)](#1-search-and-play-commands-global-actions)
- [1.1 Scenario](#11-scenario)
- [1.2 What You Will Receive](#12-what-you-will-receive)
- [1.3 Implementation Requirements](#13-implementation-requirements)
- [2. Media Controls and UI Controls (Foreground App Control)](#2-media-controls-and-ui-controls-foreground-app-control)
- [2.1 Scenario](#21-scenario)
- [Media Controls](#media-controls)
- [UI Controls](#ui-controls)
- [2.2 Implementation Guide](#22-implementation-guide)
- [2.3 API Reference: `com.webos.service.voiceconductor`](#23-api-reference-comwebosservicevoiceconductor)
- [Available APIs for InAppControl](#available-apis-for-inappcontrol)
- [API: `/interactor/register`](#api-interactorregister)
- [API: `/interactor/setContext`](#api-interactorsetcontext)
- [API: `/reportActionResult`](#api-reportactionresult)
- [2.4 Registration Example](#24-registration-example)
- [2.5 Voice Command Flow Diagram](#25-voice-command-flow-diagram)
- [2.6 Registering In-App Intents](#26-registering-in-app-intents)
- [Step 1: Receive setContext Command](#step-1-receive-setcontext-command)
- [Step 2: Send Intent List to Voice Framework](#step-2-send-intent-list-to-voice-framework)
- [rhk](#rhk)
- [2.7 Executing Voice Commands](#27-executing-voice-commands)
- [Step 3: Receive performAction Command](#step-3-receive-performaction-command)
- [Step 4: Process and Report Result](#step-4-process-and-report-result)
- [2.8 Feedback Object Format (Optional)](#28-feedback-object-format-optional)
- [Feedback Properties](#feedback-properties)
- [`general` Property Example](#general-property-example)
- [`voiceUi` Property Examples](#voiceui-property-examples)
- [2.9 Predefined Exception IDs](#29-predefined-exception-ids)
- [2.10 Complete In-App Intent Reference](#210-complete-in-app-intent-reference)
- [Understanding In-App Actions](#understanding-in-app-actions)
- [Intent List Table](#intent-list-table)
- [3. In-App Intent Examples](#3-in-app-intent-examples)
- [3.1 Basic Policy](#31-basic-policy)
- [3.2 Basic Payload Format](#32-basic-payload-format)
- [3.3 Detailed Intent Payload Examples](#33-detailed-intent-payload-examples)
---
## 1. Search and Play Commands (Global Actions)
### 1.1 Scenario
This feature allows your app to support search and play commands through voice. You will receive intent and keyword arguments when implementing your app according to this guide.
**Key Characteristics:**
- These commands work as **Global Actions** - they can be triggered even when your app is not in the foreground
- If the user mentions the app name, the command works globally
- If the user doesn't mention the app name, the command only works when the app is in the foreground
**Examples:**
| User Speech | Behavior |
|-------------|----------|
| "Search for Avengers on Netflix" | The keyword "Avengers" is passed to Netflix even if the user is watching Live TV. Netflix launches in the foreground. |
| "Play Avengers" | The keyword "Avengers" goes to the foreground app. If the app doesn't support webOS VUI, results are shown through LG Voice app. |
| "Search Avengers" | Same behavior as "Play Avengers" |
---
### 1.2 What You Will Receive
Your app will receive the following object as arguments with the **"relaunch"** event.
**Available Intents:**
- `SearchContent`
- `PlayContent`
**Parameter Syntax:**
```json
"params": {
"intent": "SearchContent",
"intentParam": "Avengers",
"languageCode": "en-US"
}
```
**Parameter Details:**
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `intent` | Yes | string | User intent: `SearchContent` or `PlayContent` |
| `intentParam` | Yes | string | Keyword for searching or playing |
| `languageCode` | Yes | string | Language code of NLP (e.g., `en-US`, `ko-KR`) |
| `voiceEngine` | No | string | Information about the voice assistant used by the user<br/>`amazonAlexa`<br/>`googleAssistant`<br/>`thinQtv`<br/>**Note:** Supported from webOS 6.0+ (2021 products) |
---
### 1.3 Implementation Requirements
Add the `inAppVoiceIntent` property to your app's `appinfo.json` file to receive keywords and intents from user voice commands.
**Configuration Syntax:**
```json
"inAppVoiceIntent": {
"contentTarget": {
"intent": "$INTENT",
"intentParam": "$INTENT_PARAM",
"languageCode": "$LANG_CODE",
"voiceEngine": "$VOICE_ENGINE"
},
"voiceConfig": {
"supportedIntent": ["SearchContent", "PlayContent"],
"supportedVoiceLanguage": []
}
}
```
**Configuration Parameters:**
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| **contentTarget** | | | **Parameters to receive from voice commands** |
| `intent` | Yes | string | Parameter to receive the user's intent |
| `intentParam` | Yes | string | Parameter to receive the search/play keyword |
| `languageCode` | Yes | string | Parameter to receive the NLP language code |
| `voiceEngine` | No | string | Parameter to receive voice assistant information<br/>**Note:** Supported from webOS 6.0+ (2021 products) |
| **voiceConfig** | | | **App capabilities configuration** |
| `supportedIntent` | No | array | Intents supported by your app<br/>**Examples:**<br/>`["SearchContent"]`<br/>`["PlayContent"]`<br/>`["SearchContent", "PlayContent"]` |
| `supportedVoiceLanguage` | No | array | Languages supported by your app<br/>**Format:** BCP-47 (e.g., `["en-US", "ko-KR"]`) |
---
## 2. Media Controls and UI Controls (Foreground App Control)
### 2.1 Scenario
Your app can receive voice intents to control functionality through user speech.
**Key Characteristics:**
- These controls **only work when your app is in the foreground**
- You must register only the intents that your app can actually process
- Do not register commands that your app cannot handle
**Supported Control Types:**
#### Media Controls
**Category i - Playback Controls:**
- Play previous/next content
- Skip intro
- Forward 30 seconds
- Backward 30 seconds
- Start over
- OK, Select, Toggle
**Category ii - Content Management:**
- Play N times (e.g., "Play 2 times")
- Change profile
- Add profile
- Add this content to my list
- Delete this content from my list
- Like/Dislike this content *(expected to be supported in the future)*
#### UI Controls
- OK
- Select
- Toggle
- Check
- And more...
---
### 2.2 Implementation Guide
Refer to the flow chart below to understand the sequence. Focus on the **Foreground app** block for implementation details.
![alt text](image-1.png)
ii. API: com.webos.service.voiceconductor
### 2.3 API Reference: `com.webos.service.voiceconductor`
#### Available APIs for InAppControl
- `com.webos.service.voiceconductor/interactor/register`
- `com.webos.service.voiceconductor/interactor/setContext`
- `com.webos.service.voiceconductor/interactor/reportActionResult`
---
#### API: `/interactor/register`
Register your app with the voice framework to receive voice commands.
**Parameters:**
| Parameter | Required | Type | Values | Description |
|-----------|----------|------|--------|-------------|
| `subscribe` | Yes | boolean | `true` | Must be `true` (false is not allowed) |
| `type` | Yes | string | `"foreground"` | Type cannot be customized, only `"foreground"` is supported |
---
#### API: `/interactor/setContext`
Set the voice intents that your app supports at the current time.
**Parameters:**
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `voiceTicket` | Yes | string | The value returned from `/interactor/register` |
| `inAppIntents` | Yes | array | List of intents to support at the time<br/>Refer to [In App Intent List](#c-in-app-intent-list) |
---
#### API: `/reportActionResult`
Report the result of processing a voice command.
**Parameters:**
| Parameter | Required | Type | Description |
|-----------|----------|------|-------------|
| `voiceTicket` | Yes | string | The value returned from `/interactor/register` |
| `result` | Yes | boolean | `true` if the command was processed successfully<br/>`false` if processing failed |
| `feedback` | No | object | Optional details about the result |
---
### 2.4 Registration Example
**Request:**
```javascript
com.webos.service.voiceconductor/interactor/register
{
"type": "foreground",
"subscribe": true
}
```
**Response:**
```json
{
"subscribed": true,
"returnValue": true
}
```
**Return Value Details:**
| Return Type | Payload | Description |
|-------------|---------|-------------|
| Initial Response | `{"subscribed": true, "returnValue": true}` | Registration successful |
---
### 2.5 Voice Command Flow Diagram
```mermaid
sequenceDiagram
participant MRCU as MRCU (keyfilter)
participant VoiceConductor as com.webos.service.voiceconductor<br>(voice framework)
participant VoiceEngine as com.webos.service.voiceconductor<br>(Voice Engine Plugin)
participant NLP as NLP Server
participant ForegroundApp as Foreground App
MRCU->>VoiceConductor: 1. start NLP speechStarted
VoiceConductor->>VoiceEngine: 4. recognizeIntentByVoice
VoiceEngine->>NLP: 5. request & result
NLP->>VoiceEngine:
VoiceEngine->>VoiceConductor: 6. return (recognizeIntentByVoice)
VoiceConductor->>ForegroundApp: 2. /interactor/register ("command": "setContext")
ForegroundApp->>VoiceConductor: 3. /command/setContext
VoiceConductor->>ForegroundApp: 7. /interactor/register ("command": "performAction")
ForegroundApp->>VoiceConductor: 8. /interactor/reportActionResult
VoiceConductor->>ForegroundApp: 9. update handle result
Note left of MRCU: Subscribe-reply ----><br>Call ---->
```
### 2.6 Registering In-App Intents
#### Step 1: Receive setContext Command
When your app is in the foreground, you'll receive:
```json
{
"command": "setContext",
"voiceTicket": "{{STRING}}",
"subscribed": true,
"returnValue": true
}
```
#### Step 2: Send Intent List to Voice Framework
Gather all intents your app supports and send them to the voice conductor:
**Request:**
```javascript
com.webos.service.voiceconductor/interactor/setContext
{
"voiceTicket": "{{STRING}}",
"inAppIntents": [
{
"intent": "Select",
"supportOrdinal": false,
"items": [
{
"title": "", // optional
"itemId": "{{STRING}}",
"value": ["SEE ALL"]
},
{
"itemId": "{{STRING}}",
"value": ["RECENTLY OPEN PAGES"]
}
]
}
]
}
```
rhk
---
### 2.7 Executing Voice Commands
#### Step 3: Receive performAction Command
When a user speaks a command, your app receives:
```json
{
"command": "performAction",
"voiceTicket": "{{STRING}}",
"action": {
"type": "IntentMatch",
"intent": "Select",
"itemId": "{{STRING}}"
},
"subscribed": true,
"returnValue": true
}
```
#### Step 4: Process and Report Result
After processing the command, report the result:
**Request:**
```javascript
com.webos.service.voiceconductor/interactor/reportActionResult
{
"voiceTicket": "{{STRING}}",
"result": true // true: success, false: failure
}
```
---
### 2.8 Feedback Object Format (Optional)
You can provide additional feedback when reporting action results.
#### Feedback Properties
| Property | Required | Type | Description |
|----------|----------|------|-------------|
| `general` | No | object | Reserved (Currently unsupported) |
| `voiceUi` | No | object | Declare system utterance or exception |
#### `general` Property Example
```json
{
"responseCode": "0000",
"responseMessage": "OK"
}
```
#### `voiceUi` Property Examples
**Exception:**
```json
{
"exception": "alreadyCompleted"
}
```
**System Utterance:**
```json
{
"systemUtterance": "The function is not supported"
}
```
**voiceUi Sub-Properties:**
- `systemUtterance`: Message to display to the user
- `exception`: Predefined exception type (see below)
---
### 2.9 Predefined Exception IDs
| Exception ID | Description | Use Case Example |
|--------------|-------------|------------------|
| `alreadyCompleted` | Action was processed correctly but no further action is needed | Scroll command received but page is already at the end |
---
### 2.10 Complete In-App Intent Reference
#### Understanding In-App Actions
**Control Actions:**
- You must pass both `intent` and `control` values for the actions you want to support
- The system will respond with the corresponding action data
- Request: `requests.control`
- Response: `ActionData.control`
**Ordinal Speech Support:**
- Only certain intents support ordinal speech (e.g., "Select the first one", "Play the second video")
- To enable ordinal speech, set `"supportOrdinal": true` in your intent configuration
- Supported intents: `Select`, `SelectRadioItem`, `SelectCheckItem`, `SetToggleItem`, `PlayContent`, `Delete`, `Show`, `Hide`
#### Intent List Table
| # | Intent Type | Control Action | Ordinal | Multi-Item | Example User Speech | Status |
|---|-------------|----------------|---------|------------|---------------------|--------|
| **1** | `Select` | - | ✅ | ✅ | "Okay", "Select Okay" | ✅ |
| **2** | `Scroll` | - | ❌ | ✅ | "Scroll up/down" | ✅ |
| **3** | `SelectRadioItem` | - | ✅ | ✅ | "Select [Radio Button Name]" | ✅ |
| **4** | `SelectCheckItem` | - | ✅ | ✅ | "Select/Deselect [Checkbox Name]" | ✅ |
| **5** | `SetToggleItem` | - | ✅ | ✅ | "Turn on/off [Toggle Item Name]" | ✅ |
| **6** | `PlayContent` | - | ✅ | ✅ | "Play [Content Name]" | ✅ |
| **7** | `PlayListControl` | - | ❌ | ❌ | "Play previous/next content" | ✅ |
| **8** | `Delete` | - | ✅ | ✅ | "Delete [Content Name]" | ✅ |
| **9** | `Zoom` | - | ❌ | ❌ | "Zoom in/out" | ✅ |
| **10** | *(Reserved)* | - | - | - | - | - |
| **11** | `ControlMedia` | `skipIntro` | ❌ | ❌ | "Skip Intro" | ✅ |
| **12** | `ControlMedia` | `forward` | ❌ | ❌ | "Forward 30 seconds"<br/>"Fast forward 1 minute" | ✅ |
| **13** | `ControlMedia` | `backward` | ❌ | ❌ | "Backward 30 seconds"<br/>"Rewind 30 seconds" | ✅ |
| **14** | `ControlMedia` | `move` | ❌ | ❌ | "Start over"<br/>"Play from 2:30"<br/>"Skip to 1:30" | ✅ |
| **15** | `ControlMedia` | `speed` | ❌ | ❌ | "Play at 1.5x speed"<br/>"Play 2x speed" | ✅ |
| **16** | `ControlMedia` | `defaultSpeed` | ❌ | ❌ | "Play at default speed" | ✅ |
| **17** | `ControlMedia` | `playLikeList` | ❌ | ❌ | "Play liked songs/videos" | ✅ |
| **18** | `ControlMedia` | `playSubscriptionList` | ❌ | ❌ | "Play subscriptions" | ✅ |
| **19** | `ControlMedia` | `playWatchLaterList` | ❌ | ❌ | "Play watch later" | ✅ |
| **20** | `ControlMedia` | `playMyPlaylist` | ❌ | ❌ | "Play my [playlist name]" | ✅ |
| **21** | `ControlMedia` | `sendToDevice` | ❌ | ❌ | "Send this to my phone" | ✅ |
| **22** | `ControlMedia` | `skipAd` | ❌ | ❌ | "Skip ad" | ✅ |
| **23** | `ControlMedia` | `play` | ❌ | ❌ | "Play" | ✅ |
| **24** | `ControlMedia` | `pause` | ❌ | ❌ | "Pause" | ✅ |
| **25** | `ControlMedia` | `nextChapter` | ❌ | ❌ | "Next chapter" | ✅ |
| **26** | `ControlMedia` | `previousChapter` | ❌ | ❌ | "Previous chapter" | ✅ |
| **27** | `ControlMedia` | `shuffle` | ❌ | ❌ | "Shuffle" | ✅ |
| **28** | `ControlMedia` | `repeat` | ❌ | ❌ | "Repeat" | ✅ |
| **29** | `SetMediaOption` | `turnCaptionOn` | ❌ | ❌ | "Turn on caption" | ✅ |
| **30** | `SetMediaOption` | `turnCaptionOff` | ❌ | ❌ | "Turn off caption" | ✅ |
| **31** | `SetMediaOption` | `selectLanguage` | ❌ | ❌ | "Set caption language to English"<br/>"Set audio to default" | ✅ |
| **32** | `RateContents` | `likeContents` | ❌ | ❌ | "Like this content/song/video"<br/>"Thumbs up" | ✅ |
| **33** | `RateContents` | `cancelLike` | ❌ | ❌ | "Remove like from this content" | ✅ |
| **34** | `RateContents` | `dislikeContents` | ❌ | ❌ | "Dislike this content/song/video" | ✅ |
| **35** | `RateContents` | `cancelDislike` | ❌ | ❌ | "Remove dislike from this content" | ✅ |
| **36** | `RateContents` | `rateContents` | ❌ | ❌ | "Rate this content"<br/>"Rate this 5 points" | ✅ |
| **37** | `RateContents` | `cancelRating` | ❌ | ❌ | "Cancel the rating"<br/>"Remove this rating" | ✅ |
| **38** | `RateContents` | `addToMyList` | ❌ | ❌ | "Add this to My list"<br/>"Add to favorites"<br/>"Save to watch later" | ✅ |
| **39** | `RateContents` | `removeFromMyList` | ❌ | ❌ | "Delete from My list"<br/>"Remove from favorites" | ✅ |
| **40** | `RateContents` | `likeAndSubscribe` | ❌ | ❌ | "Like and subscribe" | ✅ |
| **41** | `RateContents` | `subscribe` | ❌ | ❌ | "Subscribe"<br/>"Subscribe to this channel" | ✅ |
| **42** | `RateContents` | `unsubscribe` | ❌ | ❌ | "Unsubscribe"<br/>"Unsubscribe from this channel" | ✅ |
| **43** | `DisplayList` | `displayMyList` | ❌ | ❌ | "Show me my favorites"<br/>"Show my playlists" | ✅ |
| **44** | `DisplayList` | `displayRecentHistory` | ❌ | ❌ | "What have I watched lately?" | ✅ |
| **45** | `DisplayList` | `displayPurchaseHistory` | ❌ | ❌ | "What have I purchased lately?" | ✅ |
| **46** | `DisplayList` | `displayRecommendedContents` | ❌ | ❌ | "Recommend me something to watch" | ✅ |
| **47** | `DisplayList` | `displaySimilarContents` | ❌ | ❌ | "Search for something similar" | ✅ |
| **48** | `DisplayList` | `displayLikeList` | ❌ | ❌ | "Browse liked videos" | ✅ |
| **49** | `DisplayList` | `displaySubscriptionList` | ❌ | ❌ | "Browse subscriptions"<br/>"Show my subscriptions" | ✅ |
| **50** | `DisplayList` | `displayWatchLaterList` | ❌ | ❌ | "Browse watch later"<br/>"Show Watch Later playlist" | ✅ |
| **51** | `ControlStorage` | `addToWatchLater` | ❌ | ❌ | "Add to watch later"<br/>"Add to Watch Later Playlist" | ✅ |
| **52** | `ControlStorage` | `removeFromWatchLater` | ❌ | ❌ | "Remove from watch later" | ✅ |
| **53** | `ControlStorage` | `addToMyPlaylist` | ❌ | ❌ | "Add video to my [playlist name]"<br/>"Add song to [playlist name]" | ✅ |
| **54** | `ControlStorage` | `removeFromMyPlaylist` | ❌ | ❌ | "Remove video from [playlist name]" | ✅ |
| **55** | `UseIME` | - | ❌ | ❌ | N/A | - |
| **56** | `Show` | - | ✅ | ✅ | "Show [Content Name]" | ✅ |
| **57** | `Hide` | - | ✅ | ✅ | "Hide [Content Name]" | ✅ |
**Legend:**
- **Ordinal**: Supports ordinal speech (e.g., "Select the first one")
- **Multi-Item**: Supports multiple items (2+ items)
---
## 3. In-App Intent Examples
### 3.1 Basic Policy
**Important Rules:**
- `itemId` within each Intent must be a globally unique value
- `PlayListControl` and `Zoom` intents can only register one instance
- Ordinal speech is only supported for: `Select`, `SelectRadioItem`, `SelectCheckItem`, `SetToggleItem`, `PlayContent`, `Delete`, `Show`, `Hide`
---
### 3.2 Basic Payload Format
**setContext Request Structure:**
```json
{
"voiceTicket": "{{STRING}}", // Ticket value from voice framework
"inAppIntents": [ // Array of in-app intents
{
// ... Intent configuration ...
}
]
}
```
---
### 3.3 Detailed Intent Payload Examples
The following section provides detailed payload examples for each intent type, showing both the request (app → voiceframework) and response (voiceframework → app) formats.

File diff suppressed because it is too large Load Diff