Files
shoptime/com.twin.app.shoptime/vui-react.md
optrader 9dd5897c24 [251016] fix: VoiceInputOverlay ShopperHouse connect
🕐 커밋 시간: 2025. 10. 16. 10:26:16

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +65줄
  • 삭제: -36줄

📁 추가된 파일:
  + com.twin.app.shoptime/vui-implement.md
  + com.twin.app.shoptime/vui-react.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/searchActions.js (javascript):
    🔄 Modified: updateSearchTimestamp()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less (unknown):
     Added: scaleY()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript):
     Added: VoicePromptScreen()
     Deleted: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/vui-implement.md (md파일):
     Added: 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파일):
     Added: 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()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • 개발 문서 및 가이드 개선
2025-10-16 10:26:18 +09:00

26 KiB

[251015] React 프로젝트 VUI 구현 완벽 가이드

📚 목차


개요

이 문서는 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/registersubscribe: true로 호출하면, 음성 명령이 있을 때마다 같은 콜백이 계속 호출됩니다!

// ❌ 잘못된 이해: 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 설정:

{
  "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 구현

// 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 플래그 처리

// 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 생성

// 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 추가

// 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 생성

// 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에서 사용

// 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. 전체 구현 체크리스트

  • appinfo.json 수정

    • inAppVoiceIntent 섹션 추가
    • supportedIntentSearchContent, 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 호출 실패

해결:

// ✅ subscribe: true 확인
registerVoiceConductor({
  subscribe: true,  // 필수!
  ...
});

문제 2: STT 텍스트를 받지 못함

원인:

  • setContext에서 UseIME intent를 등록하지 않음
  • performAction 이벤트 처리 누락

해결:

// 1. Intent 등록 확인
setVoiceContext([{
  intent: "UseIME",
  supportAsrOnly: true,
}]);

// 2. performAction 처리 확인
if (response.command === "performAction") {
  console.log(response.action.value);  // STT 텍스트
}

문제 3: 패널이 닫혀도 음성 입력이 계속 처리됨

원인:

  • unregisterVoiceConductor 호출 누락

해결:

// useEffect cleanup에서 반드시 호출
useEffect(() => {
  // ... 등록 로직

  return () => {
    unregisterVoiceConductor();  // ✅ 필수!
  };
}, [isActive]);

문제 4: 브라우저에서 에러 발생

원인:

  • PalmSystem이 없는 환경에서 Luna Service 호출

해결:

// 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/registersubscribe: 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