diff --git a/com.twin.app.shoptime/src/App/App.js b/com.twin.app.shoptime/src/App/App.js index defc0d1c..64799da6 100644 --- a/com.twin.app.shoptime/src/App/App.js +++ b/com.twin.app.shoptime/src/App/App.js @@ -63,6 +63,7 @@ import css from './App.module.less'; import { handleBypassLink } from './bypassLinkHandler'; import { handleDeepLink } from './deepLinkHandler'; import { sendLogTotalRecommend } from '../actions/logActions'; +import { types } from '../actions/actionTypes'; // import { // startFocusMonitoring, // stopFocusMonitoring, @@ -109,15 +110,158 @@ const processArgs = (args) => { }); }; +// Voice 관련 로그를 VoicePanel로 전송하는 헬퍼 함수 +const sendVoiceLogToPanel = (args) => { + try { + const firstArg = args[0]; + // [Voice] 또는 [VoiceConductor] 태그 확인 + if ( + typeof firstArg === 'string' && + (firstArg.includes('[Voice]') || firstArg.includes('[VoiceConductor]')) + ) { + // 로그 타입 결정 + let logType = 'INFO'; + let title = firstArg; + + if ( + firstArg.includes('ERROR') || + firstArg.includes('Error') || + firstArg.includes('Failed') || + firstArg.includes('failed') + ) { + logType = 'ERROR'; + } else if ( + firstArg.includes('Response') || + firstArg.includes('response') || + firstArg.includes('success') + ) { + logType = 'RESPONSE'; + } else if ( + firstArg.includes('Registering') || + firstArg.includes('Sending') || + firstArg.includes('request') + ) { + logType = 'REQUEST'; + } else if ( + firstArg.includes('received') || + firstArg.includes('Handling') || + firstArg.includes('⭐') + ) { + logType = 'ACTION'; + } else if (firstArg.includes('command')) { + logType = 'COMMAND'; + } + + // 데이터 수집 + const logData = {}; + if (args.length > 1) { + args.slice(1).forEach((arg, index) => { + if (typeof arg === 'object') { + Object.assign(logData, arg); + } else { + logData[`arg${index + 1}`] = arg; + } + }); + } + + // Redux로 dispatch + store.dispatch({ + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type: logType, + title: title.replace(/^\[Voice\]\s*/, '').replace(/^\[VoiceConductor\]\s*/, ''), + data: Object.keys(logData).length > 0 ? logData : { message: firstArg }, + success: logType !== 'ERROR', + }, + }); + } + } catch (error) { + // 로깅 중 에러가 발생해도 원래 로그는 출력되어야 함 + originalConsoleLog.call(console, '[VoiceLog] Error sending to panel:', error); + } +}; + console.log = function (...args) { + // Voice 로그를 VoicePanel로 전송 + sendVoiceLogToPanel(args); + // 원래 console.log 실행 originalConsoleLog.apply(console, processArgs(args)); }; console.error = function (...args) { + // Voice 로그를 VoicePanel로 전송 (에러는 강제로 ERROR 타입) + try { + const firstArg = args[0]; + if ( + typeof firstArg === 'string' && + (firstArg.includes('[Voice]') || firstArg.includes('[VoiceConductor]')) + ) { + const logData = {}; + if (args.length > 1) { + args.slice(1).forEach((arg, index) => { + if (typeof arg === 'object') { + Object.assign(logData, arg); + } else { + logData[`arg${index + 1}`] = arg; + } + }); + } + + store.dispatch({ + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type: 'ERROR', + title: firstArg.replace(/^\[Voice\]\s*/, '').replace(/^\[VoiceConductor\]\s*/, ''), + data: Object.keys(logData).length > 0 ? logData : { message: firstArg }, + success: false, + }, + }); + } + } catch (error) { + originalConsoleError.call(console, '[VoiceLog] Error sending error to panel:', error); + } + originalConsoleError.apply(console, processArgs(args)); }; console.warn = function (...args) { + // Voice 로그를 VoicePanel로 전송 (경고는 ERROR 타입으로) + try { + const firstArg = args[0]; + if ( + typeof firstArg === 'string' && + (firstArg.includes('[Voice]') || firstArg.includes('[VoiceConductor]')) + ) { + const logData = {}; + if (args.length > 1) { + args.slice(1).forEach((arg, index) => { + if (typeof arg === 'object') { + Object.assign(logData, arg); + } else { + logData[`arg${index + 1}`] = arg; + } + }); + } + + store.dispatch({ + type: types.VOICE_ADD_LOG, + payload: { + timestamp: new Date().toISOString(), + type: 'ERROR', + title: + 'WARNING: ' + + firstArg.replace(/^\[Voice\]\s*/, '').replace(/^\[VoiceConductor\]\s*/, ''), + data: Object.keys(logData).length > 0 ? logData : { message: firstArg }, + success: false, + }, + }); + } + } catch (error) { + originalConsoleWarn.call(console, '[VoiceLog] Error sending warning to panel:', error); + } + originalConsoleWarn.apply(console, processArgs(args)); }; diff --git a/com.twin.app.shoptime/src/actions/voiceActions.js b/com.twin.app.shoptime/src/actions/voiceActions.js index 7d23efda..c7d77fd0 100644 --- a/com.twin.app.shoptime/src/actions/voiceActions.js +++ b/com.twin.app.shoptime/src/actions/voiceActions.js @@ -99,20 +99,30 @@ export const registerVoiceFramework = () => (dispatch, getState) => { // performAction command received if (res.command === 'performAction' && res.action) { - console.log('[Voice] performAction command received:', res.action); + console.log('[Voice] ⭐⭐⭐ performAction command received:', res.action); + + // ⭐ 중요: performAction 수신 성공 로그 (명확하게) dispatch( - addLog('COMMAND', 'performAction Command Received', { + addLog('COMMAND', '✅ performAction RECEIVED!', { + message: '✅ SUCCESS! Voice framework sent performAction event.', command: res.command, action: res.action, + intent: res.action?.intent, + value: res.action?.value || res.action?.itemId, }) ); + dispatch({ type: types.VOICE_PERFORM_ACTION, payload: res.action, }); + // Get voiceTicket from Redux state (performAction response doesn't include voiceTicket) + const { voiceTicket } = getState().voice; + console.log('[Voice] Using voiceTicket from state:', voiceTicket); + // Process the action and report result - dispatch(handleVoiceAction(res.voiceTicket, res.action)); + dispatch(handleVoiceAction(voiceTicket, res.action)); } }, @@ -215,6 +225,36 @@ export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => { type: types.VOICE_SET_CONTEXT_SUCCESS, payload: res, }); + + // ⭐ 중요: Voice input 대기 상태 로그 + dispatch( + addLog('ACTION', '🎤 Ready for Voice Input', { + message: 'Context set successfully. Press the MIC button on remote and speak.', + nextStep: 'Waiting for performAction event...', + voiceTicket: voiceTicket, + }) + ); + + // 15초 후에도 performAction이 안 오면 경고 로그 (타이머) + setTimeout(() => { + const currentState = getState().voice; + // lastSTTText가 업데이트되지 않았으면 (performAction이 안 왔으면) + if (!currentState.lastSTTText || currentState.sttTimestamp < Date.now() - 14000) { + dispatch( + addLog('ERROR', '⚠️ No performAction received yet', { + 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.', + }) + ); + } + }, 15000); }, onFailure: (err) => { diff --git a/com.twin.app.shoptime/vui-guide.2.md b/com.twin.app.shoptime/vui-guide.2.md new file mode 100644 index 00000000..c8286686 --- /dev/null +++ b/com.twin.app.shoptime/vui-guide.2.md @@ -0,0 +1,347 @@ +# `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는 외부 공개용이 아닙니다. \ No newline at end of file