[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:
@@ -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 시스템 서비스와의 안정적이고 효율적인 통신을 구현하고 있습니다.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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는 외부 공개용이 아닙니다.
|
||||
@@ -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 액션 확인
|
||||
|
||||
---
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
Reference in New Issue
Block a user