diff --git a/.gitignore b/.gitignore index 5ef6a52..920cf37 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# npm config (contains sensitive tokens) +.npmrc + # vercel .vercel diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d1e4302..0000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -child-concurrency=1 -@team-numberone:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${NPM_TOKEN} diff --git a/app/(default)/_hooks/useCarousel.ts b/app/(default)/_hooks/useCarousel.ts new file mode 100644 index 0000000..8a34f86 --- /dev/null +++ b/app/(default)/_hooks/useCarousel.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface UseCarouselOptions { + items: T[]; + onSelect?: (item: T) => void; +} + +export function useCarousel({ + items, + onSelect, +}: UseCarouselOptions) { + const [currentIndex, setCurrentIndex] = useState(0); + const onSelectRef = useRef(onSelect); + const isInitialMount = useRef(true); + + // onSelect의 최신 값을 ref에 저장 + useEffect(() => { + onSelectRef.current = onSelect; + }, [onSelect]); + + const currentItem = items?.[currentIndex]; + + // currentIndex가 변경될 때만 상위로 전달 (초기 마운트 제외) + useEffect(() => { + // 초기 마운트 시에는 실행하지 않음 + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + if (currentItem && onSelectRef.current) { + onSelectRef.current(currentItem); + } + }, [currentItem]); + + const handlePrevious = () => { + setCurrentIndex((prev) => { + const newIndex = prev === 0 ? items.length - 1 : prev - 1; + const newItem = items[newIndex]; + if (newItem) { + onSelect?.(newItem); + } + return newIndex; + }); + }; + + const handleNext = () => { + setCurrentIndex((prev) => { + const newIndex = prev === items.length - 1 ? 0 : prev + 1; + const newItem = items[newIndex]; + if (newItem) { + onSelect?.(newItem); + } + return newIndex; + }); + }; + + const handleDotClick = (index: number) => { + setCurrentIndex(index); + const item = items[index]; + if (item) { + onSelect?.(item); + } + }; + + return { + currentIndex, + currentItem, + handlePrevious, + handleNext, + handleDotClick, + }; +} diff --git a/app/(default)/_hooks/useRandomPractice.ts b/app/(default)/_hooks/useRandomPractice.ts new file mode 100644 index 0000000..f6f2f2b --- /dev/null +++ b/app/(default)/_hooks/useRandomPractice.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + detailSituations, + type SituationId, +} from "../practice/_constants/detailSituations"; + +export function useRandomPractice() { + const router = useRouter(); + + const getRandomSituation = () => { + // 모든 상세상황을 평탄화하여 배열로 만들기 + const allDetailSituations: Array<{ + situationId: SituationId; + detailId: string; + }> = []; + + (Object.keys(detailSituations) as SituationId[]).forEach((situationId) => { + detailSituations[situationId].forEach((detail) => { + allDetailSituations.push({ + situationId, + detailId: detail.id, + }); + }); + }); + + // 랜덤으로 하나 선택 + const randomIndex = Math.floor(Math.random() * allDetailSituations.length); + return allDetailSituations[randomIndex]; + }; + + const navigateToRandomPractice = () => { + const selected = getRandomSituation(); + router.push(`/practice/${selected.situationId}/${selected.detailId}/dial`); + }; + + return { + navigateToRandomPractice, + }; +} diff --git a/app/(default)/components/EmergencySituationCarousel.tsx b/app/(default)/components/EmergencySituationCarousel.tsx index cf135b8..279c808 100644 --- a/app/(default)/components/EmergencySituationCarousel.tsx +++ b/app/(default)/components/EmergencySituationCarousel.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; import { IconWrapper } from "@/components/icons/IconWrapper"; -import type { CarouselItem } from "../constants/practiceItems"; +import { useCarousel } from "../_hooks/useCarousel"; +import type { CarouselItem } from "../practice/_constants/practiceItems"; interface EmergencySituationCarouselProps { items: CarouselItem[]; @@ -13,41 +13,24 @@ export function EmergencySituationCarousel({ items, onSelect, }: EmergencySituationCarouselProps) { - const [currentIndex, setCurrentIndex] = useState(0); - - const currentItem = items?.[currentIndex]; - - // 초기 선택된 항목을 상위로 전달 (훅은 early return 전에 호출되어야 함) - useEffect(() => { - if (currentItem && onSelect) { - onSelect(currentItem.id); - } - }, [currentItem, onSelect]); + const { + currentIndex, + currentItem, + handlePrevious, + handleNext, + handleDotClick, + } = useCarousel({ + items, + onSelect: onSelect + ? (item) => { + onSelect(item.id); + } + : undefined, + }); // 안전장치: items가 비어있으면 렌더링 X if (!items || items.length === 0) return null; - const handlePrevious = () => { - setCurrentIndex((prev) => { - const newIndex = prev === 0 ? items.length - 1 : prev - 1; - onSelect?.(items[newIndex].id); - return newIndex; - }); - }; - - const handleNext = () => { - setCurrentIndex((prev) => { - const newIndex = prev === items.length - 1 ? 0 : prev + 1; - onSelect?.(items[newIndex].id); - return newIndex; - }); - }; - - const handleDotClick = (index: number) => { - setCurrentIndex(index); - onSelect?.(items[index].id); - }; - return (
{/* 캐러셀 영역(남는 세로 공간을 카드가 먹는 영역) */} diff --git a/app/(default)/components/RandomPracticeCTA.tsx b/app/(default)/components/RandomPracticeCTA.tsx index 80faf8d..862009c 100644 --- a/app/(default)/components/RandomPracticeCTA.tsx +++ b/app/(default)/components/RandomPracticeCTA.tsx @@ -1,45 +1,17 @@ "use client"; -import { useRouter } from "next/navigation"; import { IconWrapper } from "@/components/icons/IconWrapper"; -import { - detailSituations, - type SituationId, -} from "../constants/detailSituations"; +import { useRandomPractice } from "../_hooks/useRandomPractice"; import { PhoneIcon } from "./PhoneIcon"; export function RandomPracticeCTA() { - const router = useRouter(); - - const handleClick = () => { - // 모든 상세상황을 평탄화하여 배열로 만들기 - const allDetailSituations: Array<{ - situationId: SituationId; - detailId: string; - }> = []; - - (Object.keys(detailSituations) as SituationId[]).forEach((situationId) => { - detailSituations[situationId].forEach((detail) => { - allDetailSituations.push({ - situationId, - detailId: detail.id, - }); - }); - }); - - // 랜덤으로 하나 선택 - const randomIndex = Math.floor(Math.random() * allDetailSituations.length); - const selected = allDetailSituations[randomIndex]; - - // 다이얼 페이지로 이동 - router.push(`/practice/${selected.situationId}/${selected.detailId}/dial`); - }; + const { navigateToRandomPractice } = useRandomPractice(); return (
-
- ) : ( -
-
- 잘 듣고 있어요 -
-
- {/* 음성 파형 */} - - {/* 일시정지 버튼 */} + )} + + {isRecording && ( +
+
-
- )} + )} +
- {/* 전화 종료 팝업 */} - {reportId !== null && ( - { + { + if (reportId !== null) { router.push(`/report/${reportId}`); - }} - /> - )} + } else { + console.log("[Call] 리포트 ID가 없습니다. 리포트 생성 로직 필요"); + setShowEndPopup(false); + } + }} + /> ); } diff --git a/app/(default)/practice/[situationId]/[detailId]/dial/components/DialPad.tsx b/app/(default)/practice/[situationId]/[detailId]/dial/components/DialPad.tsx index 871c525..5e8952e 100644 --- a/app/(default)/practice/[situationId]/[detailId]/dial/components/DialPad.tsx +++ b/app/(default)/practice/[situationId]/[detailId]/dial/components/DialPad.tsx @@ -36,6 +36,11 @@ export function DialPad() { }; const handleStartCall = () => { + // 음성 재생 활성화를 위한 사용자 상호작용 플래그 설정 + // CallButton 클릭을 사용자 상호작용으로 인정 + if (typeof window !== "undefined") { + sessionStorage.setItem("audioEnabled", "true"); + } router.push(`/practice/${situationId}/${detailId}/dial/call`); }; diff --git a/app/(default)/practice/[situationId]/[detailId]/dial/page.tsx b/app/(default)/practice/[situationId]/[detailId]/dial/page.tsx index ff6bfec..c13808d 100644 --- a/app/(default)/practice/[situationId]/[detailId]/dial/page.tsx +++ b/app/(default)/practice/[situationId]/[detailId]/dial/page.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import { practiceItems } from "../../../../constants/practiceItems"; +import { practiceItems } from "../../../_constants/practiceItems"; import { DialPad } from "./components/DialPad"; interface DialPageProps { diff --git a/app/(default)/practice/[situationId]/[detailId]/page.tsx b/app/(default)/practice/[situationId]/[detailId]/page.tsx index 71fa9b6..bce7447 100644 --- a/app/(default)/practice/[situationId]/[detailId]/page.tsx +++ b/app/(default)/practice/[situationId]/[detailId]/page.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { detailSituations, type SituationId, -} from "../../../constants/detailSituations"; +} from "../../_constants/detailSituations"; interface DetailSituationSelectPageProps { params: Promise<{ situationId: string; detailId: string }>; diff --git a/app/(default)/practice/[situationId]/components/DetailSituationCard.tsx b/app/(default)/practice/[situationId]/components/DetailSituationCard.tsx index 2d879ec..46d12da 100644 --- a/app/(default)/practice/[situationId]/components/DetailSituationCard.tsx +++ b/app/(default)/practice/[situationId]/components/DetailSituationCard.tsx @@ -1,7 +1,7 @@ "use client"; import { IconWrapper } from "@/components/icons/IconWrapper"; -import type { DetailSituation } from "../../../constants/detailSituations"; +import type { DetailSituation } from "../../_constants/detailSituations"; interface DetailSituationCardProps { situation: DetailSituation; diff --git a/app/(default)/practice/[situationId]/components/DetailSituationList.tsx b/app/(default)/practice/[situationId]/components/DetailSituationList.tsx index 814d667..a6484a9 100644 --- a/app/(default)/practice/[situationId]/components/DetailSituationList.tsx +++ b/app/(default)/practice/[situationId]/components/DetailSituationList.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import type { DetailSituation } from "../../../constants/detailSituations"; +import type { DetailSituation } from "../../_constants/detailSituations"; import { DetailSituationCard } from "./DetailSituationCard"; interface DetailSituationListProps { diff --git a/app/(default)/practice/[situationId]/components/DetailSituationTitle.tsx b/app/(default)/practice/[situationId]/components/DetailSituationTitle.tsx index b74c26f..0d30d3a 100644 --- a/app/(default)/practice/[situationId]/components/DetailSituationTitle.tsx +++ b/app/(default)/practice/[situationId]/components/DetailSituationTitle.tsx @@ -3,8 +3,8 @@ export function DetailSituationTitle() {
- 불이 어디에 난
- 상황으로 연습해볼까요? +
세부 상황 선택
+ 어떤 상황이야?
diff --git a/app/(default)/practice/[situationId]/page.tsx b/app/(default)/practice/[situationId]/page.tsx index 83a64aa..e4f1165 100644 --- a/app/(default)/practice/[situationId]/page.tsx +++ b/app/(default)/practice/[situationId]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import { detailSituations, type SituationId, -} from "../../constants/detailSituations"; +} from "../_constants/detailSituations"; import { DetailSituationList } from "./components/DetailSituationList"; import { DetailSituationTitle } from "./components/DetailSituationTitle"; @@ -26,7 +26,6 @@ export default async function DetailSituationPage({
- {/* 세부 상황 리스트 (스크롤 가능) */}
); diff --git a/app/(default)/practice/_components/PracticeQuestionContent.tsx b/app/(default)/practice/_components/PracticeQuestionContent.tsx new file mode 100644 index 0000000..9e7051d --- /dev/null +++ b/app/(default)/practice/_components/PracticeQuestionContent.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { PracticeQuestion } from "../_constants/practiceQuestions"; + +interface PracticeQuestionContentProps { + question: PracticeQuestion; +} + +export function PracticeQuestionContent({ + question, +}: PracticeQuestionContentProps) { + const handleOptionClick = (optionId: string) => { + // TODO: 선택지 클릭 처리 로직 구현 + console.log("Selected option:", optionId); + }; + + return ( +
+
+ {/* 카드 */} +
+ {/* 질문 */} +
+

어떤 상황이야?

+

{question.situation}

+
+ + {/* 캐릭터/일러스트 영역 */} + {question.character && ( +
{question.character}
+ )} + + {/* 선택지 */} +
+ {question.options.map((option) => ( + + ))} +
+ + {/* 안내 문구 */} +
+

+ 이 서비스는 실제 119 연결이 되지 않습니다. +

+
+
+
+
+ ); +} diff --git a/app/(default)/practice/[situationId]/[detailId]/components/PracticeQuestionHeader.tsx b/app/(default)/practice/_components/PracticeQuestionHeader.tsx similarity index 100% rename from app/(default)/practice/[situationId]/[detailId]/components/PracticeQuestionHeader.tsx rename to app/(default)/practice/_components/PracticeQuestionHeader.tsx diff --git a/app/(default)/constants/detailSituations.ts b/app/(default)/practice/_constants/detailSituations.ts similarity index 100% rename from app/(default)/constants/detailSituations.ts rename to app/(default)/practice/_constants/detailSituations.ts diff --git a/app/(default)/constants/practiceItems.ts b/app/(default)/practice/_constants/practiceItems.ts similarity index 100% rename from app/(default)/constants/practiceItems.ts rename to app/(default)/practice/_constants/practiceItems.ts diff --git a/app/(default)/constants/practiceQuestions.ts b/app/(default)/practice/_constants/practiceQuestions.ts similarity index 100% rename from app/(default)/constants/practiceQuestions.ts rename to app/(default)/practice/_constants/practiceQuestions.ts diff --git a/app/(default)/practice/_hooks/useCallConversation.ts b/app/(default)/practice/_hooks/useCallConversation.ts new file mode 100644 index 0000000..3b7fcd6 --- /dev/null +++ b/app/(default)/practice/_hooks/useCallConversation.ts @@ -0,0 +1,85 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ConversationScript } from "@/lib/api/bbiyoung"; + +export const QUESTIONS = [ + "어떤 일이 발생했나요?", + "그 위치가 어디인가요?", + "현재 상황을 더 자세히 설명해주세요.", + "위험한 요소가 더 있나요?", + "추가로 알려주실 것이 있나요?", +]; + +interface UseCallConversationOptions { + onComplete: (script: ConversationScript[]) => void; +} + +export function useCallConversation({ + onComplete, +}: UseCallConversationOptions) { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [hasProcessedAnswer, setHasProcessedAnswer] = useState(false); + const conversationScriptRef = useRef([]); + + // 질문 인덱스가 0으로 리셋될 때 대화 스크립트도 초기화 + useEffect(() => { + if (currentQuestionIndex === 0) { + conversationScriptRef.current = []; + } + }, [currentQuestionIndex]); + + const currentQuestion = QUESTIONS[currentQuestionIndex]; + const isLastQuestion = currentQuestionIndex === QUESTIONS.length - 1; + + const handleAnswerComplete = useCallback( + (answerText: string, resetTranscript: () => void) => { + // 이미 처리된 경우 중복 호출 방지 + if (hasProcessedAnswer) { + return; + } + + const newScript: ConversationScript = { + question: currentQuestion, + answer: answerText.trim() || "", // 빈 답변도 허용 + }; + + setHasProcessedAnswer(true); + + // 대화 스크립트에 추가 + conversationScriptRef.current = [ + ...conversationScriptRef.current, + newScript, + ]; + + // 다음 질문으로 이동 또는 완료 + if (!isLastQuestion) { + const nextIndex = currentQuestionIndex + 1; + setCurrentQuestionIndex(nextIndex); + } else { + // 마지막 질문 완료 - 콜백 호출 + onComplete(conversationScriptRef.current); + } + resetTranscript(); + }, + [ + currentQuestion, + currentQuestionIndex, + hasProcessedAnswer, + isLastQuestion, + onComplete, + ], + ); + + const resetAnswerProcessed = useCallback(() => { + setHasProcessedAnswer(false); + }, []); + + return { + currentQuestionIndex, + currentQuestion, + isLastQuestion, + handleAnswerComplete, + resetAnswerProcessed, + }; +} diff --git a/app/(default)/practice/_hooks/useCallSubmission.ts b/app/(default)/practice/_hooks/useCallSubmission.ts new file mode 100644 index 0000000..70f4168 --- /dev/null +++ b/app/(default)/practice/_hooks/useCallSubmission.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { ConversationScript } from "@/lib/api/bbiyoung"; +import { submitConversation } from "@/lib/api/bbiyoung"; +import { getSituationIdForAPI } from "../_utils/situationIdMapping"; + +interface UseCallSubmissionOptions { + detailId: string; +} + +export function useCallSubmission({ detailId }: UseCallSubmissionOptions) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [reportId, setReportId] = useState(null); + const [showEndPopup, setShowEndPopup] = useState(false); + + // 실제 클라이언트 IP 가져오기 + const getClientIP = useCallback(async (): Promise => { + try { + const response = await fetch("https://api.ipify.org?format=json"); + const data = await response.json(); + return data.ip || "192.168.1.1"; + } catch (error) { + console.error("[IP] IP 조회 실패:", error); + return "192.168.1.1"; // 기본값 + } + }, []); + + // API 호출 함수 + const handleSubmitConversation = useCallback( + async (script: ConversationScript[]) => { + if (isSubmitting) return; + + setIsSubmitting(true); + try { + const apiId = getSituationIdForAPI(detailId); + const clientIP = await getClientIP(); + + const result = await submitConversation({ + ip: clientIP, + id: String(apiId), + script, + }); + + // 팝업 표시 + setReportId(result.resultId); + setShowEndPopup(true); + } catch (error) { + console.error("[API] 오류:", error); + alert("결과를 가져오는 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }, + [detailId, isSubmitting, getClientIP], + ); + + return { + isSubmitting, + reportId, + showEndPopup, + handleSubmitConversation, + }; +} diff --git a/app/(default)/practice/_hooks/useCallTimer.ts b/app/(default)/practice/_hooks/useCallTimer.ts new file mode 100644 index 0000000..fe3ebc7 --- /dev/null +++ b/app/(default)/practice/_hooks/useCallTimer.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useCallTimer() { + const [seconds, setSeconds] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setSeconds((prev) => prev + 1); + }, 1000); + + return () => clearInterval(timer); + }, []); + + const formatTime = (totalSeconds: number) => { + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + }; + + return { + seconds, + formattedTime: formatTime(seconds), + }; +} diff --git a/app/(default)/practice/[situationId]/[detailId]/dial/call/hooks/useSpeechToText.ts b/app/(default)/practice/_hooks/useSpeechToText.ts similarity index 100% rename from app/(default)/practice/[situationId]/[detailId]/dial/call/hooks/useSpeechToText.ts rename to app/(default)/practice/_hooks/useSpeechToText.ts diff --git a/app/(default)/practice/_hooks/useVoiceDetection.ts b/app/(default)/practice/_hooks/useVoiceDetection.ts new file mode 100644 index 0000000..d5db697 --- /dev/null +++ b/app/(default)/practice/_hooks/useVoiceDetection.ts @@ -0,0 +1,283 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Detect } from "web-voice-detection"; + +const DEFAULT_SILENCE_DURATION_MS = 1500; // 기본 1.5초 +const SPEECH_THRESHOLD = 0.7; // 일상 소음 필터링을 위한 임계값 +const MIN_SPEECH_FRAMES = 3; // 최소 연속 음성 프레임 수 + +interface UseVoiceDetectionOptions { + isRecording: boolean; + onAutoPause: () => void; + silenceDurationMs?: number; // 무음 감지 시간 (기본 1500ms) +} + +export function useVoiceDetection({ + isRecording, + onAutoPause, + silenceDurationMs = DEFAULT_SILENCE_DURATION_MS, +}: UseVoiceDetectionOptions) { + const detectRef = useRef(null); + const silenceTimerRef = useRef(null); + const frameCountRef = useRef(0); + const streamRef = useRef(null); + const analyserNodeRef = useRef(null); + const speechFrameCountRef = useRef(0); // 연속 음성 프레임 카운터 + const isInitializingRef = useRef(false); // 초기화 중 플래그 + const onAutoPauseRef = useRef(onAutoPause); + const [analyserNode, setAnalyserNode] = useState(null); + + // onAutoPause ref 업데이트 + useEffect(() => { + onAutoPauseRef.current = onAutoPause; + }, [onAutoPause]); + + // Voice Detection 정리 함수 + const cleanup = useCallback(() => { + console.log("[Voice Detection] cleanup 호출"); + isInitializingRef.current = false; + + // 타이머 정리 + if (silenceTimerRef.current) { + console.log("[Voice Detection] 타이머 정리"); + clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = null; + } + + // detect 정리 (먼저 정리하여 프레임 처리가 중지되도록) + if (detectRef.current) { + try { + console.log("[Voice Detection] detect destroy 시작", { + listening: detectRef.current.listening, + }); + detectRef.current.destroy(); + console.log("[Voice Detection] detect destroy 완료"); + } catch (error) { + if (error instanceof Error && error.name === "InvalidStateError") { + console.log("[Voice Detection] AudioContext 이미 닫혀있음"); + } else { + console.warn("[Voice Detection] detect destroy 오류:", error); + } + } + detectRef.current = null; + } + + // 스트림 정리 + if (streamRef.current) { + console.log("[Voice Detection] 스트림 정리"); + streamRef.current.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + } + + analyserNodeRef.current = null; + setAnalyserNode(null); + frameCountRef.current = 0; + speechFrameCountRef.current = 0; + console.log("[Voice Detection] cleanup 완료"); + }, []); + + // 마이크 및 Voice Detection 초기화 + // biome-ignore lint/correctness/useExhaustiveDependencies: cleanup은 useCallback으로 메모이제이션되어 안정적이므로 의존성 제외 + useEffect(() => { + if (!isRecording) { + // 녹음 중지 시 정리 + if (detectRef.current || streamRef.current) { + console.log( + "[Voice Detection] isRecording이 false로 변경됨 - cleanup 호출", + ); + cleanup(); + } + isInitializingRef.current = false; + return; + } + + // 이미 초기화 중이거나 완료된 경우 재초기화 방지 + if (isInitializingRef.current || detectRef.current) { + console.log("[Voice Detection] 이미 초기화됨 - 재초기화 방지", { + isInitializing: isInitializingRef.current, + hasDetect: !!detectRef.current, + }); + return; + } + + let detectInstance: Detect | null = null; + isInitializingRef.current = true; + + const initRecording = async () => { + try { + console.log("[Voice Detection] 마이크 시작"); + // 마이크 스트림 가져오기 + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + streamRef.current = stream; + console.log("[Voice Detection] 스트림 획득 성공"); + + // web-voice-detection 초기화 + console.log("[Voice Detection] 초기화 시작..."); + const detect = await Detect.new({ + stream, + workletURL: "/worklet.js", + modelURL: "/model.onnx", + onSpeechStart: () => { + console.log("[Voice Detection] 음성 감지 시작"); + if (silenceTimerRef.current) { + console.log("[Voice Detection] 타이머 리셋"); + clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = null; + } + }, + onSpeechEnd: (audio) => { + console.log("[Voice Detection] 음성 종료", { + audioLength: audio?.length, + }); + }, + onMisfire: () => { + console.log("[Voice Detection] Misfire - 짧은 음성 감지"); + }, + onFrameProcessed: (frame) => { + // 일상 소음 필터링: 임계값을 높여서 더 확실한 음성만 감지 + const isSpeech = frame.probabilities.isSpeech > SPEECH_THRESHOLD; + + frameCountRef.current++; + + // 디버깅 로그는 처음 10개만 출력 + if (frameCountRef.current <= 10) { + const speechProb = frame.probabilities.isSpeech; + const notSpeechProb = frame.probabilities.notSpeech; + console.log("[Voice Detection] 프레임 처리", { + frameCount: frameCountRef.current, + isSpeech, + speechProb: speechProb.toFixed(3), + notSpeechProb: notSpeechProb.toFixed(3), + hasTimer: !!silenceTimerRef.current, + speechFrameCount: speechFrameCountRef.current, + }); + } + + if (isSpeech) { + // 연속 음성 프레임 카운터 증가 + speechFrameCountRef.current++; + + // 최소 3프레임 이상 연속으로 음성이 감지되어야 실제 음성으로 인정 + // (일상 소음은 보통 짧게 나타나므로) + if (speechFrameCountRef.current >= MIN_SPEECH_FRAMES) { + // 음성 감지 시 타이머가 있으면 리셋 + if (silenceTimerRef.current) { + clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = null; + } + } + } else { + // 음성이 아니면 연속 프레임 카운터 리셋 + speechFrameCountRef.current = 0; + + // 조용함 감지 시 타이머가 없으면 시작 + if (!silenceTimerRef.current) { + silenceTimerRef.current = setTimeout(() => { + console.log( + `[Voice Detection] ${silenceDurationMs}ms 경과 - 자동 일시정지`, + ); + onAutoPauseRef.current(); + }, silenceDurationMs); + } + } + }, + fftSize: 1024, + }); + + detectInstance = detect; + detectRef.current = detect; + isInitializingRef.current = false; + + console.log("[Voice Detection] 초기화 완료", { + listening: detect.listening, + analyserNode: detect.analyserNode, + }); + + if (!detect.listening) { + console.log("[Voice Detection] start() 호출 중..."); + detect.start(); + console.log("[Voice Detection] start() 호출 완료", { + listening: detect.listening, + }); + } + + // AnalyserNode 저장 + if (detect.analyserNode) { + analyserNodeRef.current = detect.analyserNode; + setAnalyserNode(detect.analyserNode); + frameCountRef.current = 0; + speechFrameCountRef.current = 0; + console.log("[Waveform] AnalyserNode 설정 완료", detect.analyserNode); + } else { + console.warn("[Waveform] AnalyserNode가 없습니다"); + } + } catch (error) { + console.error("[Voice Detection] 초기화 오류:", error); + isInitializingRef.current = false; + alert("마이크 접근 권한이 필요합니다."); + } + }; + + initRecording(); + + return () => { + console.log("[Cleanup] useEffect cleanup 시작"); + isInitializingRef.current = false; + + // 타이머 정리 + if (silenceTimerRef.current) { + clearTimeout(silenceTimerRef.current); + silenceTimerRef.current = null; + } + + // 스트림 정리 + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + } + + // detect 정리 (detectRef.current가 아직 존재하는 경우에만) + if (detectRef.current) { + try { + // detectInstance와 비교하지 않고 무조건 destroy 시도 + // (cleanup 함수에서 호출되는 경우 detectInstance가 없을 수 있음) + if (detectRef.current === detectInstance || !detectInstance) { + console.log("[Cleanup] detect destroy 시작"); + detectRef.current.destroy(); + console.log("[Cleanup] detect destroy 완료"); + } + } catch (error) { + if (error instanceof Error && error.name === "InvalidStateError") { + console.log("[Cleanup] AudioContext 이미 닫혀있음"); + } else { + console.warn("[Cleanup] detect destroy 오류:", error); + } + } + detectRef.current = null; + } + analyserNodeRef.current = null; + setAnalyserNode(null); + + // 프레임 카운터 리셋 + frameCountRef.current = 0; + speechFrameCountRef.current = 0; + + console.log("[Cleanup] useEffect cleanup 완료"); + }; + // cleanup은 useCallback으로 메모이제이션되어 있고 의존성이 없어 안정적이므로 제외 + // onAutoPause는 ref로 관리하므로 제외 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRecording, silenceDurationMs]); + + return { + analyserNode, + cleanup, + }; +} diff --git a/app/(default)/practice/[situationId]/[detailId]/dial/call/types/speech-recognition.d.ts b/app/(default)/practice/_types/speech-recognition.d.ts similarity index 100% rename from app/(default)/practice/[situationId]/[detailId]/dial/call/types/speech-recognition.d.ts rename to app/(default)/practice/_types/speech-recognition.d.ts diff --git a/app/(default)/utils/situationIdMapping.ts b/app/(default)/practice/_utils/situationIdMapping.ts similarity index 100% rename from app/(default)/utils/situationIdMapping.ts rename to app/(default)/practice/_utils/situationIdMapping.ts diff --git a/app/(default)/practice/_utils/situationImage.ts b/app/(default)/practice/_utils/situationImage.ts new file mode 100644 index 0000000..d008e40 --- /dev/null +++ b/app/(default)/practice/_utils/situationImage.ts @@ -0,0 +1,46 @@ +import { detailSituations } from "../_constants/detailSituations"; + +/** + * 상황과 페이지 번호에 따라 이미지 경로를 반환합니다. + * 각 상황의 모든 세부 상황이 같은 이미지를 사용합니다. + * @param situationId - 메인 상황 ID (fire, emergency, injury, drowning) + * @param detailId - 세부 상황 ID + * @param pageNumber - 페이지 번호 (1 또는 2) + * @returns 이미지 경로 + */ +export function getSituationImagePath( + situationId: string, + detailId: string, + pageNumber: number, +): string { + const situationMap: Record = { + fire: 1, + emergency: 2, + injury: 3, + drowning: 4, + }; + + const situationNum = situationMap[situationId]; + if (!situationNum) { + return "/dummy.png"; + } + + const situations = + detailSituations[situationId as keyof typeof detailSituations]; + const detailIndex = situations.findIndex((s) => s.id === detailId); + const detailNum = detailIndex !== -1 ? detailIndex + 1 : 0; + + if (detailNum > 0) { + const specificImagePath = `/situation/${situationNum}-${detailNum}-${pageNumber}.png`; + // NOTE: 이상적으로는 여기서 specificImagePath에 해당하는 파일이 실제로 존재하는지 확인해야 합니다. + // (예: fs.existsSync). 하지만, 클라이언트 사이드에서는 직접적인 파일 시스템 접근이 불가능하므로, + // 일단 경로를 구성하고, 이미지가 없는 경우 브라우저에서 404 에러를 반환하게 됩니다. + // 만약 파일 존재 여부 확인이 꼭 필요하다면, 별도의 API를 통해 확인해야 합니다. + // 여기서는 새로운 이미지 경로 규칙을 적용하는 데에 초점을 맞춥니다. + return specificImagePath; + } + + // 기존 이미지 경로로 fallback + const fallbackImagePath = `/situation/${situationNum}-${pageNumber}.png`; + return fallbackImagePath; +} diff --git a/app/(default)/utils/situationImage.ts b/app/(default)/utils/situationImage.ts deleted file mode 100644 index 251b50b..0000000 --- a/app/(default)/utils/situationImage.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 상황과 페이지 번호에 따라 이미지 경로를 반환합니다. - * 각 상황의 모든 세부 상황이 같은 이미지를 사용합니다. - * @param situationId - 메인 상황 ID (fire, emergency, injury, drowning) - * @param pageNumber - 페이지 번호 (1 또는 2) - * @returns 이미지 경로 - */ -export function getSituationImagePath( - situationId: string, - pageNumber: number, -): string { - // 각 상황별 이미지 매핑 (상황 번호-페이지 번호) - const imageMap: Record> = { - fire: { - 1: "/situation/1-1.png", - 2: "/situation/1-2.png", - }, - emergency: { - 1: "/situation/2-1.png", - 2: "/situation/2-2.png", - }, - injury: { - 1: "/situation/3-1.png", - 2: "/situation/3-2.png", - }, - drowning: { - 1: "/situation/4-1.png", - 2: "/situation/4-2.png", - }, - }; - - return imageMap[situationId]?.[pageNumber] || "/dummy.png"; -} diff --git a/app/(report)/report/[reportId]/components/ReportContent.tsx b/app/(report)/report/[reportId]/components/ReportContent.tsx index 0fd5bf7..c4ae317 100644 --- a/app/(report)/report/[reportId]/components/ReportContent.tsx +++ b/app/(report)/report/[reportId]/components/ReportContent.tsx @@ -1,11 +1,12 @@ "use client"; import { Button } from "@team-numberone/daepiro-design-system"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { detailSituations, type SituationId, -} from "@/app/(default)/constants/detailSituations"; +} from "@/app/(default)/practice/_constants/detailSituations"; import { IconWrapper } from "@/components/icons/IconWrapper"; import { useReport } from "../hooks/useReport"; import { @@ -107,9 +108,11 @@ export function ReportContent({ reportId }: ReportContentProps) { {/* 트로피 이미지 */}
- {result.title}
diff --git a/app/(report)/report/[reportId]/components/ReportErrorBoundary.tsx b/app/(report)/report/[reportId]/components/ReportErrorBoundary.tsx index 0318fed..63ca3b2 100644 --- a/app/(report)/report/[reportId]/components/ReportErrorBoundary.tsx +++ b/app/(report)/report/[reportId]/components/ReportErrorBoundary.tsx @@ -1,54 +1,83 @@ "use client"; -import { Component, type ReactNode } from "react"; +import { ErrorBoundary } from "react-error-boundary"; -interface ReportErrorBoundaryProps { - children: ReactNode; - fallback?: ReactNode; +interface ReportErrorFallbackProps { + error: Error; + resetErrorBoundary: () => void; } -interface ReportErrorBoundaryState { - hasError: boolean; - error: Error | null; +function ReportErrorFallback({ + error, + resetErrorBoundary, +}: ReportErrorFallbackProps) { + return ( +
+
+
+
+ 리포트를 불러오는 중 오류가 발생했습니다. +
+ {error && ( +
{error.message}
+ )} +
+ +
+
+ ); } -export class ReportErrorBoundary extends Component< - ReportErrorBoundaryProps, - ReportErrorBoundaryState -> { - constructor(props: ReportErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ReportErrorBoundaryState { - return { hasError: true, error }; - } +interface ReportErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ComponentProps["fallback"]; + onReset?: () => void; +} - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error("ReportErrorBoundary caught an error:", error, errorInfo); +export function ReportErrorBoundary({ + children, + fallback, + onReset, +}: ReportErrorBoundaryProps) { + if (fallback) { + return ( + { + console.error( + "ReportErrorBoundary caught an error:", + error, + errorInfo, + ); + }} + > + {children} + + ); } - render() { - if (this.state.hasError) { - return ( - this.props.fallback || ( -
-
-
- 리포트를 불러오는 중 오류가 발생했습니다. -
- {this.state.error && ( -
- {this.state.error.message} -
- )} -
-
- ) - ); - } - - return this.props.children; - } + return ( + ( + { + resetErrorBoundary(); + onReset?.(); + }} + /> + )} + onError={(error, errorInfo) => { + console.error("ReportErrorBoundary caught an error:", error, errorInfo); + }} + > + {children} + + ); } diff --git a/package.json b/package.json index fbd9414..60f2858 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "next": "16.1.1", "react": "19.2.3", "react-dom": "19.2.3", + "react-error-boundary": "^6.1.0", + "speak-tts": "^2.0.4", "wavesurfer.js": "^7.12.1", "web-voice-detection": "^1.0.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30c5e14..3496676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-error-boundary: + specifier: ^6.1.0 + version: 6.1.0(react@19.2.3) + speak-tts: + specifier: ^2.0.4 + version: 2.0.8 wavesurfer.js: specifier: ^7.12.1 version: 7.12.1 @@ -2606,6 +2612,11 @@ packages: peerDependencies: react: ^19.2.3 + react-error-boundary@6.1.0: + resolution: {integrity: sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2744,6 +2755,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + speak-tts@2.0.8: + resolution: {integrity: sha512-VY6Q6mRjdou6bF+x0LspvM7GJhBxHx8CLyGPTNQQ7jrztiGutyI4QNZn0cA17c4uk0FnFbA4PaMI3skeZ6PiFg==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -5596,6 +5610,10 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-error-boundary@6.1.0(react@19.2.3): + dependencies: + react: 19.2.3 + react-is@16.13.1: {} react@19.2.3: {} @@ -5785,6 +5803,8 @@ snapshots: source-map@0.6.1: {} + speak-tts@2.0.8: {} + split2@4.2.0: {} stable-hash@0.0.5: {} diff --git a/public/audio-player.html b/public/audio-player.html new file mode 100644 index 0000000..0ee1a02 --- /dev/null +++ b/public/audio-player.html @@ -0,0 +1,111 @@ + + + + + + Audio Player + + + + + diff --git a/public/audio/AUDIO_FILES_GUIDE.md b/public/audio/AUDIO_FILES_GUIDE.md new file mode 100644 index 0000000..37bd388 --- /dev/null +++ b/public/audio/AUDIO_FILES_GUIDE.md @@ -0,0 +1,147 @@ +# 음성 파일 가이드 + +각 상황별로 필요한 음성 파일 목록입니다. + +## 파일 구조 + +``` +public/audio/ +├── fire-far/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +├── fire-near/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +├── emergency-friend/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ ├── question-5.mp3 +│ └── question-6.mp3 +├── emergency-family/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +├── injury-me/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +├── injury-other/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +├── drowning-friend/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ ├── question-2.mp3 +│ ├── question-3.mp3 +│ ├── question-4.mp3 +│ └── question-5.mp3 +└── drowning-family/ + ├── start.mp3 + ├── question-1.mp3 + ├── question-2.mp3 + ├── question-3.mp3 + ├── question-4.mp3 + └── question-5.mp3 +``` + +## 각 상황별 질문 내용 + +### fire-far (멀리 난 불) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "사람이 안에 있나요?" +- question-4: "불이 많이 번졌나요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +### fire-near (바로 앞에 난 불) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "불이 많이 번졌나요?" +- question-4: "신고자 분은 안전한 곳에 계신가요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +### emergency-friend (친구가 쓰러짐) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "친구가 의식이 있나요?" +- question-4: "친구는 남자인가요, 여자인가요? 나이는요?" +- question-5: "다친 흔적이나 피가 보이나요?" +- question-6: "신고자분의 이름과 전화번호 알려주세요." + +### emergency-family (보호자가 쓰러짐) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "보호자가 의식있나요?" +- question-4: "다친 흔적이나 피가 보이나요?" +- question-5: "신고자분의 이름과 연락처를 알려주세요." + +### injury-me (내가 다침) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "의식은 괜찮으세요? 어디가 가장 아픈가요?" +- question-4: "피가 많이 나거나, 몸이 꺾여 있나요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +### injury-other (친구/가족이 다침) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "의식은 있으신가요? 말을 하거나 눈을 뜨나요?" +- question-4: "어디가 가장 아파 보이나요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +### drowning-friend (친구가 물에 빠짐) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "친구가 지금 물 밖으로 나왔나요, 아직 물 안에 있나요?" +- question-4: "절대 혼자 물에 들어가지 마시고, 튜브, 줄 같은 게 있으면 던져줄 수 있나요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +### drowning-family (가족이 물에 빠짐) +- start: "119입니다. 어떤 일이 발생했나요?" +- question-1: "119입니다. 어떤 일이 발생했나요?" +- question-2: "그 위치가 어디인가요?" +- question-3: "보호자 분이 지금 물 밖으로 나왔나요, 아직 물 안에 있나요?" +- question-4: "절대 혼자 물에 들어가지 마시고, 튜브, 줄 같은 게 있으면 던져줄 수 있나요?" +- question-5: "신고자분의 이름과 전화번호 알려주세요." + +## 음성 파일 형식 + +- **형식**: MP3 (권장) 또는 WAV, OGG +- **비트레이트**: 128kbps 이상 권장 +- **샘플레이트**: 44.1kHz 권장 +- **채널**: 모노 또는 스테레오 + +## 사용 방법 + +1. 위 질문 내용에 맞게 음성 파일을 녹음합니다. +2. 파일명을 위 구조에 맞게 명명합니다 (예: `question-1.mp3`). +3. 해당 상황의 디렉토리에 파일을 넣습니다. +4. `callScript.ts`에는 이미 `audioUrl`이 설정되어 있으므로 추가 작업이 필요 없습니다. diff --git a/public/audio/drowning-family/question-1.mp3 b/public/audio/drowning-family/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/drowning-family/question-1.mp3 differ diff --git a/public/audio/drowning-family/question-2.mp3 b/public/audio/drowning-family/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/drowning-family/question-2.mp3 differ diff --git a/public/audio/drowning-family/question-3.mp3 b/public/audio/drowning-family/question-3.mp3 new file mode 100644 index 0000000..dcad950 Binary files /dev/null and b/public/audio/drowning-family/question-3.mp3 differ diff --git a/public/audio/drowning-family/question-4.mp3 b/public/audio/drowning-family/question-4.mp3 new file mode 100644 index 0000000..cb063a2 Binary files /dev/null and b/public/audio/drowning-family/question-4.mp3 differ diff --git a/public/audio/drowning-family/question-5.mp3 b/public/audio/drowning-family/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/drowning-family/question-5.mp3 differ diff --git a/public/audio/drowning-family/start.mp3 b/public/audio/drowning-family/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/drowning-family/start.mp3 differ diff --git a/public/audio/drowning-friend/question-1.mp3 b/public/audio/drowning-friend/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/drowning-friend/question-1.mp3 differ diff --git a/public/audio/drowning-friend/question-2.mp3 b/public/audio/drowning-friend/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/drowning-friend/question-2.mp3 differ diff --git a/public/audio/drowning-friend/question-3.mp3 b/public/audio/drowning-friend/question-3.mp3 new file mode 100644 index 0000000..0355a81 Binary files /dev/null and b/public/audio/drowning-friend/question-3.mp3 differ diff --git a/public/audio/drowning-friend/question-4.mp3 b/public/audio/drowning-friend/question-4.mp3 new file mode 100644 index 0000000..cb063a2 Binary files /dev/null and b/public/audio/drowning-friend/question-4.mp3 differ diff --git a/public/audio/drowning-friend/question-5.mp3 b/public/audio/drowning-friend/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/drowning-friend/question-5.mp3 differ diff --git a/public/audio/drowning-friend/start.mp3 b/public/audio/drowning-friend/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/drowning-friend/start.mp3 differ diff --git a/public/audio/emergency-family/question-1.mp3 b/public/audio/emergency-family/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/emergency-family/question-1.mp3 differ diff --git a/public/audio/emergency-family/question-2.mp3 b/public/audio/emergency-family/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/emergency-family/question-2.mp3 differ diff --git a/public/audio/emergency-family/question-3.mp3 b/public/audio/emergency-family/question-3.mp3 new file mode 100644 index 0000000..bc7edd9 Binary files /dev/null and b/public/audio/emergency-family/question-3.mp3 differ diff --git a/public/audio/emergency-family/question-4.mp3 b/public/audio/emergency-family/question-4.mp3 new file mode 100644 index 0000000..9f4c053 Binary files /dev/null and b/public/audio/emergency-family/question-4.mp3 differ diff --git a/public/audio/emergency-family/question-5.mp3 b/public/audio/emergency-family/question-5.mp3 new file mode 100644 index 0000000..44c9c69 Binary files /dev/null and b/public/audio/emergency-family/question-5.mp3 differ diff --git a/public/audio/emergency-family/start.mp3 b/public/audio/emergency-family/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/emergency-family/start.mp3 differ diff --git a/public/audio/emergency-friend/question-1.mp3 b/public/audio/emergency-friend/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/emergency-friend/question-1.mp3 differ diff --git a/public/audio/emergency-friend/question-2.mp3 b/public/audio/emergency-friend/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/emergency-friend/question-2.mp3 differ diff --git a/public/audio/emergency-friend/question-3.mp3 b/public/audio/emergency-friend/question-3.mp3 new file mode 100644 index 0000000..263e5ff Binary files /dev/null and b/public/audio/emergency-friend/question-3.mp3 differ diff --git a/public/audio/emergency-friend/question-4.mp3 b/public/audio/emergency-friend/question-4.mp3 new file mode 100644 index 0000000..c7d003d Binary files /dev/null and b/public/audio/emergency-friend/question-4.mp3 differ diff --git a/public/audio/emergency-friend/question-5.mp3 b/public/audio/emergency-friend/question-5.mp3 new file mode 100644 index 0000000..9f4c053 Binary files /dev/null and b/public/audio/emergency-friend/question-5.mp3 differ diff --git a/public/audio/emergency-friend/question-6.mp3 b/public/audio/emergency-friend/question-6.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/emergency-friend/question-6.mp3 differ diff --git a/public/audio/emergency-friend/start.mp3 b/public/audio/emergency-friend/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/emergency-friend/start.mp3 differ diff --git a/public/audio/fire-far/README.md b/public/audio/fire-far/README.md new file mode 100644 index 0000000..ad3a8fb --- /dev/null +++ b/public/audio/fire-far/README.md @@ -0,0 +1,45 @@ +# 음성 파일 디렉토리 + +이 디렉토리에는 `fire-far` 상황에 대한 음성 파일들이 들어갑니다. + +## 파일 구조 + +``` +public/audio/ +├── fire-far/ +│ ├── start.mp3 # 시작 음성: "119입니다. 어떤 일이 발생했나요?" +│ ├── question-1.mp3 # 질문 1: "119입니다. 어떤 일이 발생했나요?" +│ ├── question-2.mp3 # 질문 2: "그 위치가 어디인가요?" +│ ├── question-3.mp3 # 질문 3: "사람이 더 있나요?" +│ ├── question-4.mp3 # 질문 4: "불이 번지나요?" +│ └── question-5.mp3 # 질문 5: "신고자분의 이름과 연락처로 알려주세요." +├── fire-near/ +│ └── ... +├── emergency-friend/ +│ └── ... +└── ... +``` + +## 음성 파일 형식 + +- **형식**: MP3 (권장) 또는 WAV, OGG +- **비트레이트**: 128kbps 이상 권장 +- **샘플레이트**: 44.1kHz 권장 +- **채널**: 모노 또는 스테레오 + +## 사용 방법 + +1. 음성 파일을 녹음하거나 준비합니다. +2. 파일명을 위 구조에 맞게 명명합니다. +3. `callScript.ts`의 `audioUrl` 필드에 경로를 지정합니다. + +예시: +```typescript +{ + category: "무엇이", + question: "119입니다. 어떤 일이 발생했나요?", + hintTitle: "무슨일이", + hintDescription: "일어났는지 천천히 말해봐요", + audioUrl: "/audio/fire-far/question-1.mp3", +} +``` diff --git a/public/audio/fire-far/question-1.mp3 b/public/audio/fire-far/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/fire-far/question-1.mp3 differ diff --git a/public/audio/fire-far/question-2.mp3 b/public/audio/fire-far/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/fire-far/question-2.mp3 differ diff --git a/public/audio/fire-far/question-3.mp3 b/public/audio/fire-far/question-3.mp3 new file mode 100644 index 0000000..895d035 Binary files /dev/null and b/public/audio/fire-far/question-3.mp3 differ diff --git a/public/audio/fire-far/question-4.mp3 b/public/audio/fire-far/question-4.mp3 new file mode 100644 index 0000000..9401e33 Binary files /dev/null and b/public/audio/fire-far/question-4.mp3 differ diff --git a/public/audio/fire-far/question-5.mp3 b/public/audio/fire-far/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/fire-far/question-5.mp3 differ diff --git a/public/audio/fire-far/start.mp3 b/public/audio/fire-far/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/fire-far/start.mp3 differ diff --git a/public/audio/fire-near/question-1.mp3 b/public/audio/fire-near/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/fire-near/question-1.mp3 differ diff --git a/public/audio/fire-near/question-2.mp3 b/public/audio/fire-near/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/fire-near/question-2.mp3 differ diff --git a/public/audio/fire-near/question-3.mp3 b/public/audio/fire-near/question-3.mp3 new file mode 100644 index 0000000..9401e33 Binary files /dev/null and b/public/audio/fire-near/question-3.mp3 differ diff --git a/public/audio/fire-near/question-4.mp3 b/public/audio/fire-near/question-4.mp3 new file mode 100644 index 0000000..74657cc Binary files /dev/null and b/public/audio/fire-near/question-4.mp3 differ diff --git a/public/audio/fire-near/question-5.mp3 b/public/audio/fire-near/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/fire-near/question-5.mp3 differ diff --git a/public/audio/fire-near/start.mp3 b/public/audio/fire-near/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/fire-near/start.mp3 differ diff --git a/public/audio/injury-me/question-1.mp3 b/public/audio/injury-me/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/injury-me/question-1.mp3 differ diff --git a/public/audio/injury-me/question-2.mp3 b/public/audio/injury-me/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/injury-me/question-2.mp3 differ diff --git a/public/audio/injury-me/question-3.mp3 b/public/audio/injury-me/question-3.mp3 new file mode 100644 index 0000000..46bf53d Binary files /dev/null and b/public/audio/injury-me/question-3.mp3 differ diff --git a/public/audio/injury-me/question-4.mp3 b/public/audio/injury-me/question-4.mp3 new file mode 100644 index 0000000..b6ad670 Binary files /dev/null and b/public/audio/injury-me/question-4.mp3 differ diff --git a/public/audio/injury-me/question-5.mp3 b/public/audio/injury-me/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/injury-me/question-5.mp3 differ diff --git a/public/audio/injury-me/start.mp3 b/public/audio/injury-me/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/injury-me/start.mp3 differ diff --git a/public/audio/injury-other/question-1.mp3 b/public/audio/injury-other/question-1.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/injury-other/question-1.mp3 differ diff --git a/public/audio/injury-other/question-2.mp3 b/public/audio/injury-other/question-2.mp3 new file mode 100644 index 0000000..5143e6c Binary files /dev/null and b/public/audio/injury-other/question-2.mp3 differ diff --git a/public/audio/injury-other/question-3.mp3 b/public/audio/injury-other/question-3.mp3 new file mode 100644 index 0000000..41020f7 Binary files /dev/null and b/public/audio/injury-other/question-3.mp3 differ diff --git a/public/audio/injury-other/question-4.mp3 b/public/audio/injury-other/question-4.mp3 new file mode 100644 index 0000000..87cfca3 Binary files /dev/null and b/public/audio/injury-other/question-4.mp3 differ diff --git a/public/audio/injury-other/question-5.mp3 b/public/audio/injury-other/question-5.mp3 new file mode 100644 index 0000000..bc929e3 Binary files /dev/null and b/public/audio/injury-other/question-5.mp3 differ diff --git a/public/audio/injury-other/start.mp3 b/public/audio/injury-other/start.mp3 new file mode 100644 index 0000000..9d1df92 Binary files /dev/null and b/public/audio/injury-other/start.mp3 differ diff --git a/public/reset.svg b/public/reset.svg new file mode 100644 index 0000000..2f388b8 --- /dev/null +++ b/public/reset.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/situation/1-1-1.png b/public/situation/1-1-1.png new file mode 100644 index 0000000..6a3b1d6 Binary files /dev/null and b/public/situation/1-1-1.png differ diff --git a/public/situation/1-1-2.png b/public/situation/1-1-2.png new file mode 100644 index 0000000..f680c7a Binary files /dev/null and b/public/situation/1-1-2.png differ diff --git a/public/situation/1-2-1.png b/public/situation/1-2-1.png new file mode 100644 index 0000000..13694df Binary files /dev/null and b/public/situation/1-2-1.png differ diff --git a/public/situation/1-2-2.png b/public/situation/1-2-2.png new file mode 100644 index 0000000..9f62561 Binary files /dev/null and b/public/situation/1-2-2.png differ diff --git a/public/situation/2-1-1.png b/public/situation/2-1-1.png new file mode 100644 index 0000000..483aec0 Binary files /dev/null and b/public/situation/2-1-1.png differ diff --git a/public/situation/2-1-2.png b/public/situation/2-1-2.png new file mode 100644 index 0000000..b046680 Binary files /dev/null and b/public/situation/2-1-2.png differ diff --git a/public/situation/2-2-1.png b/public/situation/2-2-1.png new file mode 100644 index 0000000..a6eae74 Binary files /dev/null and b/public/situation/2-2-1.png differ diff --git a/public/situation/2-2-2.png b/public/situation/2-2-2.png new file mode 100644 index 0000000..ead0960 Binary files /dev/null and b/public/situation/2-2-2.png differ diff --git a/public/situation/3-1-1.png b/public/situation/3-1-1.png new file mode 100644 index 0000000..8ee85f2 Binary files /dev/null and b/public/situation/3-1-1.png differ diff --git a/public/situation/3-1-2.png b/public/situation/3-1-2.png new file mode 100644 index 0000000..0f2fbea Binary files /dev/null and b/public/situation/3-1-2.png differ diff --git a/public/situation/3-2-1.png b/public/situation/3-2-1.png new file mode 100644 index 0000000..3a81641 Binary files /dev/null and b/public/situation/3-2-1.png differ diff --git a/public/situation/3-2-2.png b/public/situation/3-2-2.png new file mode 100644 index 0000000..d5f3bfd Binary files /dev/null and b/public/situation/3-2-2.png differ diff --git a/public/situation/4-1-1.png b/public/situation/4-1-1.png new file mode 100644 index 0000000..5b6f21f Binary files /dev/null and b/public/situation/4-1-1.png differ diff --git a/public/situation/4-1-2.png b/public/situation/4-1-2.png new file mode 100644 index 0000000..bfb9aec Binary files /dev/null and b/public/situation/4-1-2.png differ diff --git a/public/situation/4-2-1.png b/public/situation/4-2-1.png new file mode 100644 index 0000000..1ccb11f Binary files /dev/null and b/public/situation/4-2-1.png differ diff --git a/public/situation/4-2-2.png b/public/situation/4-2-2.png new file mode 100644 index 0000000..1392868 Binary files /dev/null and b/public/situation/4-2-2.png differ diff --git a/public/start-situation/start-situation-4.png b/public/start-situation/start-situation-4.png index af30d53..da02b47 100644 Binary files a/public/start-situation/start-situation-4.png and b/public/start-situation/start-situation-4.png differ diff --git a/scripts/GOOGLE_TTS_SETUP.md b/scripts/GOOGLE_TTS_SETUP.md new file mode 100644 index 0000000..fbd8fdb --- /dev/null +++ b/scripts/GOOGLE_TTS_SETUP.md @@ -0,0 +1,91 @@ +# Google TTS API 설정 가이드 + +Google Cloud Text-to-Speech API를 사용하여 더 자연스러운 음성을 생성하는 방법입니다. + +## 1단계: Google Cloud 프로젝트 생성 + +1. [Google Cloud Console](https://console.cloud.google.com/) 접속 +2. 새 프로젝트 생성 (또는 기존 프로젝트 선택) + - 프로젝트 이름: 예) "119-web-client-tts" + +## 2단계: Text-to-Speech API 활성화 + +1. Google Cloud Console에서 **"API 및 서비스" > "라이브러리"** 이동 +2. 검색창에 **"Text-to-Speech API"** 입력 +3. **"Cloud Text-to-Speech API"** 선택 +4. **"사용 설정"** 클릭 + +## 3단계: API 키 생성 + +1. **"API 및 서비스" > "사용자 인증 정보"** 이동 +2. 상단 **"+ 사용자 인증 정보 만들기"** 클릭 +3. **"API 키"** 선택 +4. 생성된 API 키 복사 (예: `AIzaSyC...`) + +⚠️ **보안 주의**: API 키는 공개 저장소에 올리지 마세요! + +## 4단계: 환경변수 설정 + +### macOS/Linux (터미널에서) + +```bash +# 현재 세션에만 적용 +export GOOGLE_TTS_API_KEY="your_api_key_here" + +# 영구적으로 적용 (추천) +echo 'export GOOGLE_TTS_API_KEY="your_api_key_here"' >> ~/.zshrc +source ~/.zshrc +``` + +### Windows (PowerShell) + +```powershell +# 현재 세션에만 적용 +$env:GOOGLE_TTS_API_KEY="your_api_key_here" + +# 영구적으로 적용 +[System.Environment]::SetEnvironmentVariable('GOOGLE_TTS_API_KEY', 'your_api_key_here', 'User') +``` + +## 5단계: 스크립트 실행 + +```bash +# 모든 상황의 음성 파일 생성 +GOOGLE_TTS_API_KEY=your_api_key node scripts/generate-audio-files.js --api google + +# 또는 환경변수가 이미 설정되어 있다면 +node scripts/generate-audio-files.js --api google + +# 특정 상황만 생성 +node scripts/generate-audio-files.js --api google --situation fire-far +``` + +## 가격 정보 + +- **무료 할당량**: 월 0-4백만 문자 (WaveNet/Standard 음성) +- **유료**: $4 per 1백만 문자 (WaveNet/Standard) +- **신규 고객**: $300 무료 크레딧 제공 + +현재 프로젝트는 약 1,000-2,500자이므로 **무료 할당량으로 충분**합니다. + +## 문제 해결 + +### "API key not valid" 오류 +- API 키가 올바른지 확인 +- Text-to-Speech API가 활성화되었는지 확인 +- 환경변수가 제대로 설정되었는지 확인: `echo $GOOGLE_TTS_API_KEY` + +### "API has not been used" 오류 +- API가 활성화되기까지 몇 분 걸릴 수 있습니다 +- 잠시 후 다시 시도하세요 + +### 요금 걱정 +- 무료 할당량(4백만 문자/월)을 초과하지 않으면 요금이 부과되지 않습니다 +- 현재 프로젝트는 매우 작은 규모이므로 걱정하지 않아도 됩니다 + +## 음성 품질 비교 + +- **macOS say**: 기계적, 빠름, 무료 +- **Google TTS**: 자연스러움, 약간 느림, 무료 할당량 있음 + +Google TTS가 훨씬 더 자연스럽고 전문적인 음성을 제공합니다. diff --git a/scripts/GOOGLE_TTS_TROUBLESHOOTING.md b/scripts/GOOGLE_TTS_TROUBLESHOOTING.md new file mode 100644 index 0000000..525ac5f --- /dev/null +++ b/scripts/GOOGLE_TTS_TROUBLESHOOTING.md @@ -0,0 +1,93 @@ +# Google TTS API 502 에러 해결 가이드 + +## 502 에러 발생 원인 + +### 1. **일시적 서버 오류 (가장 흔함)** +- Google 서버 측 일시적 문제 +- 특정 텍스트 패턴에서 간헐적으로 발생 +- **해결**: 재시도 로직이 자동으로 처리합니다 (최대 3회) + +### 2. **요청 빈도 문제** +- 너무 빠르게 연속 요청 시 발생 +- **해결**: 각 요청 사이에 500ms 딜레이가 있습니다 + +### 3. **API 키/인증 문제** +- 잘못된 API 키 +- API가 활성화되지 않음 +- 프로젝트 설정 문제 +- **확인 방법**: + ```bash + # API 키 확인 + echo $GOOGLE_TTS_API_KEY + + # API 활성화 확인 + # Google Cloud Console > API 및 서비스 > 사용 설정된 API + ``` + +### 4. **텍스트 길이/형식 문제** +- 특정 텍스트 패턴에서 간헐적 오류 +- 5000자 제한 근처에서 발생 가능 +- **해결**: 현재 프로젝트는 짧은 텍스트만 사용하므로 문제 없음 + +## 해결 방법 + +### 자동 재시도 +스크립트에 재시도 로직이 포함되어 있습니다: +- **최대 3회 재시도** +- **지수 백오프**: 1초 → 2초 → 4초 대기 +- **502, 500, 503 에러만 재시도** (인증 오류 등은 재시도 안 함) + +### 수동 해결 + +1. **잠시 후 다시 시도** + ```bash + # 30초~1분 후 다시 실행 + node scripts/generate-audio-from-callscript.js --api google + ``` + +2. **API 키 확인** + ```bash + # 환경변수 확인 + echo $GOOGLE_TTS_API_KEY + + # 다시 설정 + export GOOGLE_TTS_API_KEY="your_api_key" + ``` + +3. **Google Cloud Console 확인** + - [API 및 서비스 > 사용 설정된 API](https://console.cloud.google.com/apis/library) + - "Cloud Text-to-Speech API"가 활성화되어 있는지 확인 + - API 키가 제한되어 있는지 확인 (IP 제한 등) + +4. **특정 상황만 재생성** + ```bash + # 실패한 상황만 다시 생성 + node scripts/generate-audio-from-callscript.js --api google --situation fire-far + ``` + +## 예상되는 동작 + +재시도 로직이 포함된 스크립트 실행 시: + +``` +📝 start.mp3 + "일일구입니다. 어떤 일이 발생했나요?" + ⚠ 502 발생, 1000ms 후 재시도 (1/3)... + ✓ MP3 생성 완료 (Google TTS) (재시도 2회) +``` + +## 여전히 실패하는 경우 + +1. **API 키 문제**: Google Cloud Console에서 새 API 키 생성 +2. **할당량 초과**: 무료 할당량(4백만 문자/월) 확인 +3. **네트워크 문제**: 인터넷 연결 확인 +4. **macOS say 사용**: Google TTS 대신 macOS say 명령어 사용 + ```bash + node scripts/generate-audio-from-callscript.js + ``` + +## 참고 + +- 502 에러는 Google 서버 측 문제이므로 대부분 일시적입니다 +- 재시도 로직으로 대부분의 경우 자동 해결됩니다 +- 지속적으로 발생하면 Google Cloud 지원팀에 문의하세요 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..4f99a3d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,105 @@ +# 음성 파일 생성 스크립트 + +모든 질문 텍스트를 자동으로 음성 파일로 변환하는 스크립트입니다. + +## 사용 방법 + +### 방법 1: macOS say 명령어 사용 (권장, 간단함) + +```bash +# 모든 상황의 음성 파일 생성 +node scripts/generate-audio-from-callscript.js + +# 특정 상황만 생성 +node scripts/generate-audio-from-callscript.js --situation fire-far +``` + +**장점:** +- 추가 설치 불필요 (macOS 기본 제공) +- 무료 +- 즉시 사용 가능 + +**단점:** +- 음질이 다소 기계적 +- AIFF 파일로 생성되며, MP3 변환을 위해 `ffmpeg` 필요 + +**MP3 변환:** +```bash +# ffmpeg 설치 +brew install ffmpeg + +# 변환 (스크립트가 자동으로 변환 시도) +``` + +### 방법 2: Google TTS API 사용 (더 나은 품질) + +```bash +# API 키 설정 후 실행 +GOOGLE_TTS_API_KEY=your_api_key node scripts/generate-audio-files.js --api google +``` + +**장점:** +- 자연스러운 음성 품질 +- MP3 형식으로 직접 생성 +- 한국어 최적화 + +**단점:** +- Google Cloud 계정 및 API 키 필요 +- 유료 (월 무료 할당량 있음) + +**Google TTS API 키 발급:** +1. [Google Cloud Console](https://console.cloud.google.com/) 접속 +2. 프로젝트 생성 +3. "Text-to-Speech API" 활성화 +4. API 키 생성 +5. 환경변수로 설정 + +## 스크립트 설명 + +### `generate-audio-from-callscript.js` (권장) +- `callScript.ts` 파일을 직접 읽어서 텍스트 추출 +- 가장 정확하고 최신 데이터 사용 +- macOS `say` 명령어 사용 + +### `generate-audio-files.js` +- Google TTS API 지원 포함 +- 더 복잡한 기능 제공 + +### `generate-audio-files-simple.js` +- `AUDIO_FILES_GUIDE.md` 기반으로 생성 +- 간단하지만 가이드 파일과 동기화 필요 + +## 출력 파일 위치 + +생성된 파일은 `public/audio/{situationId}/` 디렉토리에 저장됩니다: + +``` +public/audio/ +├── fire-far/ +│ ├── start.mp3 +│ ├── question-1.mp3 +│ └── ... +├── fire-near/ +│ └── ... +└── ... +``` + +## 문제 해결 + +### "say: command not found" +- macOS에서만 사용 가능합니다 +- Linux/Windows에서는 다른 방법 사용 필요 + +### "ffmpeg: command not found" +- AIFF 파일이 생성되지만 MP3 변환 실패 +- `brew install ffmpeg`로 설치 후 재실행 + +### 음성 품질이 마음에 들지 않음 +- Google TTS API 사용 권장 +- 또는 실제 사람이 녹음한 파일로 교체 + +## 참고 + +- 생성된 파일은 `callScript.ts`의 `audioUrl` 경로와 일치합니다 +- 기존 파일이 있으면 덮어씁니다 +- HTML 태그(`
` 등)는 자동으로 제거됩니다 diff --git a/scripts/generate-audio-files-simple.js b/scripts/generate-audio-files-simple.js new file mode 100755 index 0000000..e0cd0dc --- /dev/null +++ b/scripts/generate-audio-files-simple.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +/** + * 간단한 버전: macOS say 명령어만 사용 + * callScript.ts를 직접 파싱하지 않고, AUDIO_FILES_GUIDE.md를 기반으로 생성 + */ + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const AUDIO_BASE_DIR = path.join(__dirname, "../public/audio"); +const GUIDE_PATH = path.join(__dirname, "../public/audio/AUDIO_FILES_GUIDE.md"); + +// AUDIO_FILES_GUIDE.md에서 텍스트 추출 +function extractTextsFromGuide() { + const content = fs.readFileSync(GUIDE_PATH, "utf-8"); + const scripts = {}; + + // 각 상황별 섹션 추출 + const sections = content.split(/###\s+([^\n]+)/); + + for (let i = 1; i < sections.length; i += 2) { + const situationName = sections[i].trim(); + const sectionContent = sections[i + 1]; + + // 상황 ID 추출 (예: "fire-far (멀리 난 불)" -> "fire-far") + const situationMatch = situationName.match(/^([a-z-]+)\s*\(/); + if (!situationMatch) continue; + + const situationId = situationMatch[1]; + const items = {}; + + // start와 question들 추출 + const lines = sectionContent.split("\n"); + for (const line of lines) { + const match = line.match(/^- (start|question-\d+):\s*"([^"]+)"/); + if (match) { + items[match[1]] = match[2]; + } + } + + if (Object.keys(items).length > 0) { + scripts[situationId] = items; + } + } + + return scripts; +} + +// HTML 태그 제거 및 숫자 변환 +function cleanText(text) { + let cleaned = text + .replace(//gi, " ") + .replace(/<\/?[^>]+(>|$)/g, " ") + .replace(/\s+/g, " ") + .trim(); + + // "119"를 "일일구"로 변환 + cleaned = cleaned.replace(/\b119\b/g, "일일구"); + + return cleaned; +} + +// macOS say 명령어로 음성 파일 생성 +function generateWithSay(text, outputPath) { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + console.log(`[say] 생성 중: ${path.basename(outputPath)}`); + console.log( + ` 텍스트: ${text.substring(0, 50)}${text.length > 50 ? "..." : ""}`, + ); + + const tempFile = outputPath.replace(".mp3", ".aiff"); + + try { + // say 명령어 실행 (한국어 음성: Yuna 사용) + execSync(`say -v Yuna -o "${tempFile}" "${cleanText(text)}"`, { + stdio: "pipe", + }); + + // ffmpeg로 MP3 변환 시도 + try { + execSync( + `ffmpeg -i "${tempFile}" -acodec libmp3lame -ab 128k "${outputPath}" -y -loglevel error`, + { stdio: "pipe" }, + ); + fs.unlinkSync(tempFile); + console.log(` ✓ 완료\n`); + return true; + } catch (_e) { + // ffmpeg가 없으면 AIFF 파일 유지 + console.log(` ✓ AIFF 파일 생성 완료: ${tempFile}`); + console.log( + ` ⚠ MP3 변환을 위해 ffmpeg를 설치하거나 수동으로 변환해주세요.\n`, + ); + return true; + } + } catch (error) { + console.error(` ✗ 오류: ${error.message}\n`); + return false; + } +} + +// 메인 함수 +function main() { + const args = process.argv.slice(2); + const situationFilter = args.includes("--situation") + ? args[args.indexOf("--situation") + 1] + : null; + + console.log("📝 AUDIO_FILES_GUIDE.md에서 텍스트 추출 중...\n"); + const scripts = extractTextsFromGuide(); + + const situations = situationFilter ? [situationFilter] : Object.keys(scripts); + + console.log(`🎤 총 ${situations.length}개 상황의 음성 파일 생성 시작...\n`); + + let totalFiles = 0; + let successCount = 0; + + for (const situationId of situations) { + if (!scripts[situationId]) { + console.warn(`⚠ 상황을 찾을 수 없습니다: ${situationId}`); + continue; + } + + const items = scripts[situationId]; + const audioDir = path.join(AUDIO_BASE_DIR, situationId); + + console.log(`\n📁 [${situationId}] 처리 중...`); + + // start 파일 생성 + if (items.start) { + const outputPath = path.join(audioDir, "start.mp3"); + totalFiles++; + if (generateWithSay(items.start, outputPath)) { + successCount++; + } + } + + // question 파일들 생성 + for (const [key, text] of Object.entries(items)) { + if (key.startsWith("question-")) { + const questionNum = key.replace("question-", ""); + const outputPath = path.join(audioDir, `question-${questionNum}.mp3`); + totalFiles++; + if (generateWithSay(text, outputPath)) { + successCount++; + } + } + } + } + + console.log(`\n${"=".repeat(50)}`); + console.log(`✅ 완료!`); + console.log(` 총 파일: ${totalFiles}개`); + console.log(` 성공: ${successCount}개`); + console.log("=".repeat(50)); + console.log("\n💡 참고:"); + console.log(" - AIFF 파일이 생성된 경우, ffmpeg로 MP3 변환이 필요합니다."); + console.log(" - ffmpeg 설치: brew install ffmpeg"); + console.log( + " - 변환 명령어: ffmpeg -i input.aiff -acodec libmp3lame -ab 128k output.mp3", + ); +} + +main(); diff --git a/scripts/generate-audio-files.js b/scripts/generate-audio-files.js new file mode 100755 index 0000000..61dbd7a --- /dev/null +++ b/scripts/generate-audio-files.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +/** + * 음성 파일 자동 생성 스크립트 + * + * 사용 방법: + * 1. macOS say 명령어 사용 (기본): + * node scripts/generate-audio-files.js + * + * 2. Google TTS API 사용 (더 나은 품질): + * GOOGLE_TTS_API_KEY=your_api_key node scripts/generate-audio-files.js --api google + * + * 3. 특정 상황만 생성: + * node scripts/generate-audio-files.js --situation fire-far + */ + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +// callScript.ts 파일 경로 +const CALL_SCRIPT_PATH = path.join( + __dirname, + "../app/(default)/practice/[situationId]/[detailId]/dial/call/constants/callScript.ts", +); +const AUDIO_BASE_DIR = path.join(__dirname, "../public/audio"); + +// callScript.ts 파일을 읽어서 텍스트 추출 +function extractTextsFromCallScript() { + const content = fs.readFileSync(CALL_SCRIPT_PATH, "utf-8"); + const scripts = {}; + + // CALL_SCRIPTS 객체 내부의 각 상황별로 파싱 + // 상황 ID와 그 다음 중괄호 블록을 찾기 + const situationPattern = /"([a-z-]+)":\s*\{/g; + let match; + const situationIds = []; + + // 모든 상황 ID 찾기 + // biome-ignore lint/suspicious/noAssignInExpressions: 정규식 exec 패턴 + while ((match = situationPattern.exec(content)) !== null) { + situationIds.push({ + id: match[1], + startPos: match.index + match[0].length, + }); + } + + // 각 상황별로 블록 추출 + for (let i = 0; i < situationIds.length; i++) { + const situationId = situationIds[i].id; + const startPos = situationIds[i].startPos; + const endPos = + i < situationIds.length - 1 + ? situationIds[i + 1].startPos - 10 + : content.length; + + const blockContent = content.substring(startPos, endPos); + + // start 텍스트와 startAudioUrl 추출 + const startMatch = blockContent.match(/start:\s*"([^"]+)"/); + const startAudioMatch = blockContent.match(/startAudioUrl:\s*"([^"]+)"/); + + if (!startMatch) { + console.warn(`⚠ ${situationId}: start 텍스트를 찾을 수 없습니다.`); + continue; + } + + const startText = startMatch[1]; + const startAudioUrl = startAudioMatch ? startAudioMatch[1] : null; + + // questions 배열 추출 - 더 정확한 패턴 사용 + const questions = []; + const questionPattern = + /\{\s*question:\s*"([^"]+)"[\s\S]*?audioUrl:\s*"([^"]+)"/g; + let questionMatch; + + // biome-ignore lint/suspicious/noAssignInExpressions: 정규식 exec 패턴 + while ((questionMatch = questionPattern.exec(blockContent)) !== null) { + questions.push({ + text: questionMatch[1], + audioUrl: questionMatch[2], + }); + } + + if (questions.length === 0) { + console.warn(`⚠ ${situationId}: 질문을 찾을 수 없습니다.`); + continue; + } + + scripts[situationId] = { + start: { text: startText, audioUrl: startAudioUrl }, + questions, + }; + } + + return scripts; +} + +// HTML 태그 제거 (예:
,
) +function cleanText(text) { + let cleaned = text + .replace(//gi, " ") + .replace(/<\/?[^>]+(>|$)/g, " ") + .replace(/\s+/g, " ") + .trim(); + + // "119"를 "일일구"로 변환 + cleaned = cleaned.replace(/\b119\b/g, "일일구"); + + return cleaned; +} + +// macOS say 명령어로 음성 파일 생성 +function generateWithSay(text, outputPath) { + console.log(`[say] 생성 중: ${outputPath}`); + console.log(` 텍스트: ${text}`); + + // say 명령어로 AIFF 파일 생성 후 ffmpeg로 MP3 변환 + const tempFile = outputPath.replace(".mp3", ".aiff"); + + try { + // say 명령어 실행 (한국어 음성: Yuna 사용) + execSync(`say -v Yuna -o "${tempFile}" "${cleanText(text)}"`, { + stdio: "inherit", + }); + + // ffmpeg로 MP3 변환 (ffmpeg가 설치되어 있는 경우) + try { + execSync( + `ffmpeg -i "${tempFile}" -acodec libmp3lame -ab 128k "${outputPath}" -y`, + { stdio: "inherit" }, + ); + fs.unlinkSync(tempFile); // 임시 파일 삭제 + console.log(` ✓ 완료: ${outputPath}\n`); + } catch (_e) { + // ffmpeg가 없으면 AIFF 파일을 그대로 사용하거나 경고 + console.warn(` ⚠ ffmpeg가 없어서 AIFF 파일로 저장됩니다: ${tempFile}`); + console.warn( + ` MP3로 변환하려면: ffmpeg -i "${tempFile}" -acodec libmp3lame -ab 128k "${outputPath}"`, + ); + } + } catch (error) { + console.error(` ✗ 오류: ${error.message}\n`); + throw error; + } +} + +// Google TTS API로 음성 파일 생성 +async function generateWithGoogleTTS(text, outputPath, apiKey) { + console.log(`[Google TTS] 생성 중: ${outputPath}`); + console.log(` 텍스트: ${text}`); + + const https = require("https"); + const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`; + + const data = JSON.stringify({ + input: { text: cleanText(text) }, + voice: { + languageCode: "ko-KR", + name: "ko-KR-Wavenet-A", // 한국어 여성 음성 + ssmlGender: "FEMALE", + }, + audioConfig: { + audioEncoding: "MP3", + speakingRate: 1.0, + pitch: 0, + }, + }); + + return new Promise((resolve, reject) => { + const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": data.length, + }, + }; + + const req = https.request(url, options, (res) => { + let responseData = ""; + + res.on("data", (chunk) => { + responseData += chunk; + }); + + res.on("end", () => { + if (res.statusCode === 200) { + const result = JSON.parse(responseData); + const audioContent = Buffer.from(result.audioContent, "base64"); + fs.writeFileSync(outputPath, audioContent); + console.log(` ✓ 완료: ${outputPath}\n`); + resolve(); + } else { + reject( + new Error( + `Google TTS API 오류: ${res.statusCode} - ${responseData}`, + ), + ); + } + }); + }); + + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +// 메인 함수 +async function main() { + const args = process.argv.slice(2); + const useGoogleTTS = + args.includes("--api") && args[args.indexOf("--api") + 1] === "google"; + const situationFilter = args.includes("--situation") + ? args[args.indexOf("--situation") + 1] + : null; + + if (useGoogleTTS && !process.env.GOOGLE_TTS_API_KEY) { + console.error( + "❌ Google TTS API를 사용하려면 GOOGLE_TTS_API_KEY 환경변수를 설정해주세요.", + ); + console.error( + " 예: GOOGLE_TTS_API_KEY=your_key node scripts/generate-audio-files.js --api google", + ); + process.exit(1); + } + + console.log("📝 callScript.ts에서 텍스트 추출 중...\n"); + const scripts = extractTextsFromCallScript(); + + const situations = situationFilter ? [situationFilter] : Object.keys(scripts); + + console.log(`🎤 총 ${situations.length}개 상황의 음성 파일 생성 시작...\n`); + + let totalFiles = 0; + let successCount = 0; + let errorCount = 0; + + for (const situationId of situations) { + if (!scripts[situationId]) { + console.warn(`⚠ 상황을 찾을 수 없습니다: ${situationId}`); + continue; + } + + const script = scripts[situationId]; + const audioDir = path.join(AUDIO_BASE_DIR, situationId); + + // 디렉토리 생성 + if (!fs.existsSync(audioDir)) { + fs.mkdirSync(audioDir, { recursive: true }); + } + + console.log(`\n📁 [${situationId}] 처리 중...`); + + // start 파일 생성 + if (script.start?.audioUrl) { + // /audio/... 경로를 public/audio/...로 변환 + const audioPath = script.start.audioUrl.startsWith("/") + ? script.start.audioUrl.substring(1) + : script.start.audioUrl; + const outputPath = path.join(__dirname, "..", "public", audioPath); + totalFiles++; + try { + if (useGoogleTTS) { + await generateWithGoogleTTS( + script.start.text, + outputPath, + process.env.GOOGLE_TTS_API_KEY, + ); + } else { + generateWithSay(script.start.text, outputPath); + } + successCount++; + } catch (error) { + console.error(` ✗ 실패: ${error.message}`); + errorCount++; + } + } + + // question 파일들 생성 + for (const question of script.questions) { + if (question.audioUrl) { + // /audio/... 경로를 public/audio/...로 변환 + const audioPath = question.audioUrl.startsWith("/") + ? question.audioUrl.substring(1) + : question.audioUrl; + const outputPath = path.join(__dirname, "..", "public", audioPath); + totalFiles++; + try { + if (useGoogleTTS) { + await generateWithGoogleTTS( + question.text, + outputPath, + process.env.GOOGLE_TTS_API_KEY, + ); + } else { + generateWithSay(question.text, outputPath); + } + successCount++; + + // API rate limit 방지를 위한 딜레이 (Google TTS만) + if (useGoogleTTS) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + console.error(` ✗ 실패: ${error.message}`); + errorCount++; + } + } + } + } + + console.log(`\n${"=".repeat(50)}`); + console.log(`✅ 완료!`); + console.log(` 총 파일: ${totalFiles}개`); + console.log(` 성공: ${successCount}개`); + console.log(` 실패: ${errorCount}개`); + console.log("=".repeat(50)); +} + +main().catch(console.error); diff --git a/scripts/generate-audio-from-callscript.js b/scripts/generate-audio-from-callscript.js new file mode 100755 index 0000000..2f41d04 --- /dev/null +++ b/scripts/generate-audio-from-callscript.js @@ -0,0 +1,438 @@ +#!/usr/bin/env node + +/** + * callScript.ts에서 직접 텍스트를 추출하여 음성 파일 생성 + * + * 사용 방법: + * 1. macOS say 명령어 사용 (기본): + * node scripts/generate-audio-from-callscript.js + * + * 2. Google TTS API 사용 (더 나은 품질): + * GOOGLE_TTS_API_KEY=your_api_key node scripts/generate-audio-from-callscript.js --api google + * + * 3. 특정 상황만 생성: + * node scripts/generate-audio-from-callscript.js --situation fire-far + */ + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const CALL_SCRIPT_PATH = path.join( + __dirname, + "../app/(default)/practice/[situationId]/[detailId]/dial/call/constants/callScript.ts", +); +const AUDIO_BASE_DIR = path.join(__dirname, "../public/audio"); + +// callScript.ts 파일을 읽어서 텍스트 추출 +function extractTextsFromCallScript() { + const content = fs.readFileSync(CALL_SCRIPT_PATH, "utf-8"); + const scripts = {}; + + // CALL_SCRIPTS 객체 내부의 각 상황별로 파싱 + // 상황 ID와 그 다음 중괄호 블록을 찾기 + const situationPattern = /"([a-z-]+)":\s*\{/g; + let match; + const situationIds = []; + + // 모든 상황 ID 찾기 + // biome-ignore lint/suspicious/noAssignInExpressions: 정규식 exec 패턴 + while ((match = situationPattern.exec(content)) !== null) { + situationIds.push({ + id: match[1], + startPos: match.index + match[0].length, + }); + } + + // 각 상황별로 블록 추출 + for (let i = 0; i < situationIds.length; i++) { + const situationId = situationIds[i].id; + const startPos = situationIds[i].startPos; + const endPos = + i < situationIds.length - 1 + ? situationIds[i + 1].startPos - 10 + : content.length; + + const blockContent = content.substring(startPos, endPos); + + // start 텍스트와 startAudioUrl 추출 + const startMatch = blockContent.match(/start:\s*"([^"]+)"/); + const startAudioMatch = blockContent.match(/startAudioUrl:\s*"([^"]+)"/); + + if (!startMatch) { + console.warn(`⚠ ${situationId}: start 텍스트를 찾을 수 없습니다.`); + continue; + } + + const startText = startMatch[1]; + const startAudioUrl = startAudioMatch ? startAudioMatch[1] : null; + + // questions 배열 추출 - 더 정확한 패턴 사용 + const questions = []; + const questionPattern = + /\{\s*question:\s*"([^"]+)"[\s\S]*?audioUrl:\s*"([^"]+)"/g; + let questionMatch; + + // biome-ignore lint/suspicious/noAssignInExpressions: 정규식 exec 패턴 + while ((questionMatch = questionPattern.exec(blockContent)) !== null) { + questions.push({ + text: questionMatch[1], + audioUrl: questionMatch[2], + }); + } + + if (questions.length === 0) { + console.warn(`⚠ ${situationId}: 질문을 찾을 수 없습니다.`); + continue; + } + + scripts[situationId] = { + start: { text: startText, audioUrl: startAudioUrl }, + questions, + }; + } + + return scripts; +} + +// HTML 태그 제거 및 숫자 변환 +function cleanText(text) { + let cleaned = text + .replace(//gi, " ") + .replace(/<\/?[^>]+(>|$)/g, " ") + .replace(/\s+/g, " ") + .trim(); + + // "119"를 "일일구"로 변환 + cleaned = cleaned.replace(/\b119\b/g, "일일구"); + + return cleaned; +} + +// macOS say 명령어로 음성 파일 생성 +function generateWithSay(text, outputPath) { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const fileName = path.basename(outputPath); + const displayText = text.length > 60 ? `${text.substring(0, 60)}...` : text; + + console.log(` 📝 ${fileName}`); + console.log(` "${displayText}"`); + + const tempFile = outputPath.replace(".mp3", ".aiff"); + + try { + // say 명령어 실행 (한국어 음성: Yuna 사용) + // 특수문자 이스케이프 처리 + const escapedText = cleanText(text).replace(/"/g, '\\"'); + execSync(`say -v Yuna -o "${tempFile}" "${escapedText}"`, { + stdio: "pipe", + maxBuffer: 10 * 1024 * 1024, // 10MB + }); + + // ffmpeg로 MP3 변환 시도 + try { + execSync( + `ffmpeg -i "${tempFile}" -acodec libmp3lame -ab 128k -ar 44100 "${outputPath}" -y -loglevel error`, + { + stdio: "pipe", + }, + ); + fs.unlinkSync(tempFile); + console.log(` ✓ MP3 생성 완료\n`); + return true; + } catch (_e) { + // ffmpeg가 없으면 AIFF 파일 유지 + console.log(` ✓ AIFF 파일 생성 완료 (MP3 변환 필요)\n`); + return true; + } + } catch (error) { + console.error(` ✗ 오류: ${error.message}\n`); + return false; + } +} + +// Google TTS API로 음성 파일 생성 (재시도 로직 포함) +async function generateWithGoogleTTS(text, outputPath, apiKey, retries = 3) { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const fileName = path.basename(outputPath); + const displayText = text.length > 60 ? `${text.substring(0, 60)}...` : text; + + console.log(` 📝 ${fileName}`); + console.log(` "${displayText}"`); + + const https = require("https"); + const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`; + + const data = JSON.stringify({ + input: { text: cleanText(text) }, + voice: { + languageCode: "ko-KR", + name: "ko-KR-Wavenet-A", // 한국어 여성 음성 + ssmlGender: "FEMALE", + }, + audioConfig: { + audioEncoding: "MP3", + speakingRate: 1.0, + pitch: 0, + }, + }); + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const result = await new Promise((resolve, reject) => { + const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": data.length, + }, + }; + + const req = https.request(url, options, (res) => { + let responseData = ""; + + res.on("data", (chunk) => { + responseData += chunk; + }); + + res.on("end", () => { + const statusCode = res.statusCode; + if (statusCode === 200) { + try { + const result = JSON.parse(responseData); + resolve(result); + } catch (error) { + reject(new Error(`응답 파싱 오류: ${error.message}`)); + } + } else if ( + statusCode === 502 || + statusCode === 500 || + statusCode === 503 + ) { + // 일시적 서버 오류는 재시도 가능 + reject( + new Error( + `RETRYABLE_ERROR:${statusCode}:${responseData.substring(0, 200)}`, + ), + ); + } else { + // 다른 오류는 재시도하지 않음 + reject( + new Error( + `Google TTS API 오류: ${statusCode} - ${responseData.substring(0, 200)}`, + ), + ); + } + }); + }); + + req.on("error", (error) => { + reject(new Error(`네트워크 오류: ${error.message}`)); + }); + + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error("요청 타임아웃")); + }); + + req.write(data); + req.end(); + }); + + // 성공 시 파일 저장 + const audioContent = Buffer.from(result.audioContent, "base64"); + fs.writeFileSync(outputPath, audioContent); + console.log( + ` ✓ MP3 생성 완료 (Google TTS)${attempt > 1 ? ` (재시도 ${attempt}회)` : ""}\n`, + ); + return true; + } catch (error) { + const isRetryable = error.message.includes("RETRYABLE_ERROR"); + + if (isRetryable && attempt < retries) { + const delay = Math.min(1000 * 2 ** (attempt - 1), 5000); // 지수 백오프: 1초, 2초, 4초 + const statusMatch = error.message.match(/RETRYABLE_ERROR:(\d+):/); + const statusCode = statusMatch ? statusMatch[1] : "오류"; + console.log( + ` ⚠ ${statusCode} 발생, ${delay}ms 후 재시도 (${attempt}/${retries})...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + console.error( + ` ✗ 오류: ${error.message.replace("RETRYABLE_ERROR:", "").split(":").slice(1).join(":")}\n`, + ); + throw error; + } + } + } + + throw new Error("모든 재시도 실패"); +} + +// 메인 함수 +async function main() { + const args = process.argv.slice(2); + const situationFilter = args.includes("--situation") + ? args[args.indexOf("--situation") + 1] + : null; + const useGoogleTTS = + args.includes("--api") && args[args.indexOf("--api") + 1] === "google"; + const apiKey = process.env.GOOGLE_TTS_API_KEY; + + if (useGoogleTTS && !apiKey) { + console.error( + "❌ Google TTS API를 사용하려면 GOOGLE_TTS_API_KEY 환경변수를 설정해주세요.", + ); + console.error( + " 예: GOOGLE_TTS_API_KEY=your_key node scripts/generate-audio-from-callscript.js --api google", + ); + console.error("\n💡 설정 방법은 scripts/GOOGLE_TTS_SETUP.md를 참고하세요."); + process.exit(1); + } + + console.log("📖 callScript.ts 파일 읽는 중...\n"); + + let scripts; + try { + scripts = extractTextsFromCallScript(); + } catch (error) { + console.error("❌ callScript.ts 파싱 오류:", error.message); + process.exit(1); + } + + const situations = situationFilter ? [situationFilter] : Object.keys(scripts); + + if (situations.length === 0) { + console.error("❌ 생성할 상황이 없습니다."); + process.exit(1); + } + + const method = useGoogleTTS ? "Google TTS API" : "macOS say"; + console.log( + `🎤 총 ${situations.length}개 상황의 음성 파일 생성 시작... (${method})\n`, + ); + + let totalFiles = 0; + let successCount = 0; + + for (const situationId of situations) { + if (!scripts[situationId]) { + console.warn(`⚠ 상황을 찾을 수 없습니다: ${situationId}\n`); + continue; + } + + const script = scripts[situationId]; + + console.log(`📁 [${situationId}]`); + + // start 파일 생성 + if (script.start?.audioUrl) { + // /audio/... 경로를 public/audio/...로 변환 + const audioPath = script.start.audioUrl.startsWith("/") + ? script.start.audioUrl.substring(1) + : script.start.audioUrl; + const outputPath = path.join(__dirname, "..", "public", audioPath); + totalFiles++; + try { + if (useGoogleTTS) { + const result = await generateWithGoogleTTS( + script.start.text, + outputPath, + apiKey, + ); + if (result) successCount++; + } else { + if (generateWithSay(script.start.text, outputPath)) { + successCount++; + } + } + } catch (error) { + console.error(` ✗ 오류: ${error.message}\n`); + } + } + + // question 파일들 생성 + for (const question of script.questions) { + if (question.audioUrl) { + // /audio/... 경로를 public/audio/...로 변환 + const audioPath = question.audioUrl.startsWith("/") + ? question.audioUrl.substring(1) + : question.audioUrl; + const outputPath = path.join(__dirname, "..", "public", audioPath); + totalFiles++; + try { + if (useGoogleTTS) { + const result = await generateWithGoogleTTS( + question.text, + outputPath, + apiKey, + ); + if (result) { + successCount++; + // API rate limit 방지를 위한 딜레이 (500ms로 증가) + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } else { + if (generateWithSay(question.text, outputPath)) { + successCount++; + } + } + } catch (error) { + console.error(` ✗ 오류: ${error.message}\n`); + } + } + } + + console.log(""); + } + + console.log("=".repeat(60)); + console.log(`✅ 완료!`); + console.log(` 총 파일: ${totalFiles}개`); + console.log(` 성공: ${successCount}개`); + console.log("=".repeat(60)); + + // AIFF 파일이 있는지 확인 + const aiffFiles = []; + for (const situationId of situations) { + if (scripts[situationId]) { + const audioDir = path.join(AUDIO_BASE_DIR, situationId); + if (fs.existsSync(audioDir)) { + const files = fs.readdirSync(audioDir); + aiffFiles.push( + ...files + .filter((f) => f.endsWith(".aiff")) + .map((f) => path.join(audioDir, f)), + ); + } + } + } + + if (aiffFiles.length > 0) { + console.log("\n💡 AIFF 파일이 생성되었습니다. MP3로 변환하려면:"); + console.log(" 1. ffmpeg 설치: brew install ffmpeg"); + console.log(" 2. 변환 명령어:"); + aiffFiles.slice(0, 3).forEach((file) => { + const mp3File = file.replace(".aiff", ".mp3"); + console.log( + ` ffmpeg -i "${file}" -acodec libmp3lame -ab 128k -ar 44100 "${mp3File}"`, + ); + }); + if (aiffFiles.length > 3) { + console.log(` ... 외 ${aiffFiles.length - 3}개 파일`); + } + } +} + +main().catch((error) => { + console.error("❌ 실행 오류:", error.message); + process.exit(1); +});