Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app/api/tts/speak/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// app/api/tts/speak/route.ts
import { NextResponse } from 'next/server';
import textToSpeech from '@google-cloud/text-to-speech';

export async function POST(req: Request) {
try {
const { text, voiceName } = await req.json();

// 서버에서만 JSON 키 읽기
const client = new textToSpeech.TextToSpeechClient({
credentials: JSON.parse(process.env.GOOGLE_TTS_KEY!),
});

const [response] = await client.synthesizeSpeech({
input: { text },
voice: {
name: voiceName || 'ko-KR-Chirp3-HD-Fenrir',
languageCode: 'ko-KR',
},
audioConfig: { audioEncoding: 'MP3' },
});

const audioContent = response.audioContent;
if (!audioContent)
return NextResponse.json({ error: 'No audio content' }, { status: 500 });

// ArrayBuffer → Base64
const base64Audio = Buffer.from(audioContent).toString('base64');

return NextResponse.json({ audio: base64Audio });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'TTS failed' }, { status: 500 });
}
}
79 changes: 60 additions & 19 deletions app/main/conversation/calling/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,55 @@ import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { ROUTES } from '@/constants/routes';
import { useCallStore } from '@/stores/callStore';
import { useVoiceStore } from '@/stores/voiceStore';
import { useTTS } from '@/hooks/useTTS';

export default function CallingPage() {
const router = useRouter();
const decrementCall = useCallStore(state => state.decrementCall);
const selectedVoice = useVoiceStore(state => state.selectedVoice);
const { playTTS } = useTTS();

const [showConnecting, setShowConnecting] = useState(true);
const [callTime, setCallTime] = useState(0);

// textStep: 0 = 첫번째, 1 = 세번째 텍스트
const [textStep, setTextStep] = useState(0);
// ttsStep: 0 = 첫번째 TTS, 1 = 세번째 TTS
const [ttsStep, setTtsStep] = useState(0);

const CONNECTING_DURATION = 5000; // 연결 화면 유지 시간 (ms)

// 텍스트 & TTS 정의
const TEXTS = [
{
text: '안녕! 오늘은 어떤 얘기할래?',
translation: 'What do you want to talk about today?',
}, // 첫번째
{
text: '그런 일이 있었구나. 속상했겠다.. 그럼 나랑 오늘 한국어로 대화 많이 해보면서 공부해보자',
translation:
'I see… that must have been upsetting. Let’s practice a lot of Korean conversation today!',
}, // 세번째
];

// 연결 화면 타이머
useEffect(() => {
const timer = setTimeout(() => {
setShowConnecting(false);
setCallTime(0);
}, 5000);
}, CONNECTING_DURATION);
return () => clearTimeout(timer);
}, []);

// 통화 타이머
// @ts-ignore
useEffect(() => {
let interval: number | undefined;

if (!showConnecting) {
interval = window.setInterval(() => {
setCallTime(prev => prev + 1);
}, 1000);
interval = window.setInterval(() => setCallTime(prev => prev + 1), 1000);
}

return () => {
if (interval !== undefined) {
window.clearInterval(interval);
}
};
return () => interval && clearInterval(interval);
}, [showConnecting]);

const formatTime = (seconds: number) => {
Expand All @@ -45,15 +65,36 @@ export default function CallingPage() {
return `${m}:${s}`;
};

// TODO: PROTO 화면 클릭 시 TTS 재생 & 텍스트 단계 변경
const handleUserInteraction = async () => {
if (!showConnecting && selectedVoice) {
if (ttsStep === 0) {
// 첫번째 클릭: 첫번째 TTS
await playTTS(TEXTS[0].text, selectedVoice.voiceName);
setTtsStep(1); // 다음 TTS는 세번째
} else if (ttsStep === 1) {
setTtsStep(-1);
setTextStep(1);
// 두번째 클릭: 두번째 TTS (세번째 텍스트)
await playTTS(TEXTS[1].text, selectedVoice.voiceName);
console.log(ttsStep);
} else {
return;
}
}
};

const handleEndCall = () => {
decrementCall();
router.replace(ROUTES.MAIN.CONVERSATION.ROOT);
};

return (
<div className="flex flex-col h-full relative">
<div
className="flex flex-col h-full relative"
onClick={handleUserInteraction}
>
{showConnecting ? (
// 연결 중 모션
<motion.div
className="text-center text-white mt-45 mb-12"
animate={{ y: [0, -5, 0], opacity: [1, 0.7, 1] }}
Expand All @@ -64,7 +105,7 @@ export default function CallingPage() {
</motion.div>
) : (
<>
{/* 통화 시작 텍스트 모션 */}
{/* 통화 타이머 */}
<motion.div
className="text-center text-white mt-12 mb-12"
initial={{ opacity: 0 }}
Expand All @@ -76,25 +117,25 @@ export default function CallingPage() {
<h2>Kody</h2>
</motion.div>

{/* 캐릭터 번역 텍스트 모션 */}
{/* 캐릭터 대사 */}
<motion.div
className="mb-4 px-6 py-3 bg-white-70 rounded-2xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 3 }} //TODO: 시간 조절
transition={{ duration: 0.5 }}
>
<h2 className="text-secondary-300 text-bd1-bold gap-1">
안녕! 오늘은 어떤 얘기할래?
{textStep === 0 ? TEXTS[0].text : TEXTS[1].text}
</h2>
<div className="w-full border-b border-dashed border-gray-700 my-1"></div>
<p className="text-gray-500 text-trans-cp2-regular mt-1">
What do you want to talk about today?
{textStep === 0 ? TEXTS[0].translation : TEXTS[1].translation}
</p>
</motion.div>
</>
)}

{/* 이미지 영역 */}
{/* 캐릭터 + 종료 버튼 */}
<motion.div
className="flex flex-col items-center relative h-full"
initial={{ opacity: 0, scale: 0.9 }}
Expand Down
4 changes: 3 additions & 1 deletion app/main/my-learning/[level]/ending/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { useLevelParam } from '@/hooks/useLevelParam';
export default function LevelCompletionPage() {
const router = useRouter();
const { currentLanguage } = useLanguageStore();
const { username, increaseLevel } = useUserStore();
const levelParam = useLevelParam();

const handleBtnClick = () => {
increaseLevel();
router.push(ROUTES.MAIN.ROOT);
};

Expand All @@ -39,7 +41,7 @@ export default function LevelCompletionPage() {
className="mt-26 mb-8"
/>
<CharacterText
title={'내일도 즐겁게 공부해 보자!'}
title={`내일도 즐겁게 공부해 보자!`}
subtitle={"Let's have fun studying tomorrow!"}
audio
/>
Expand Down
16 changes: 8 additions & 8 deletions app/main/my-learning/[level]/step1/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,19 @@ export default function LevelStep1Page() {
//TODO: API 연결
const phrases = [
{
phrase: '너는 취미가 뭐야?',
romanization: 'Neo nun / chui mi ga / muh ya',
translation: 'What is your hobby?',
phrase: '우리 일 교시 뭐야?',
romanization: 'Uri / il / gyosi / moya?',
translation: 'What is our first class?',
},
{
phrase: '오늘 날씨 어때?',
romanization: 'Oneul nalssi eottae?',
translation: 'How is the weather today?',
phrase: '수업 언제 시작해?',
romanization: 'Sueop / eonje / sijakhae?',
translation: 'When does the class start?',
},
{
phrase: '무슨 음식을 좋아해?',
romanization: 'Museun eumsigeul joahae?',
translation: 'What food do you like?',
romanization: 'Oneul / sukjje / da / haesseo?',
translation: 'Did you finish today’s homework?',
},
];

Expand Down
44 changes: 23 additions & 21 deletions app/main/my-learning/[level]/step2/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,33 @@ type QuestionItem = {

const questions: QuestionItem[] = [
{
question: '안녕 너는 좋아하는 게 뭐야?',
questionTrans: 'What do you like?',
hint: '취미가 뭐야? 라고 말해 봐!',
translation: 'What is your hobby?',
correction: '안녕! 너는 취미가 뭐야?',
explanation: '문장 시작에 ~ 자연스러운 대화가 됩니다.',
answer: '그렇구나! 나도 이거 좋아해!',
question: '안녕 오늘 첫 수업 뭐였지?',
questionTrans: 'Hi what is first class today again?',
hint: 'Start by saying a subject in Korean!',
translation: 'I think it was Korean class',
correction: '국어 시간이었던 것 같아.',
explanation:
'‘이였던거’는 ‘였던 것’으로 교정할 수 있어요. 또한, ‘같아’는 ‘같애’로 바꾸는 것이 더 자연스러워요.',
answer: '맞아, 국어 시간이지! 알려줘서 고마워!',
},
{
question: '너는 주말에 뭐 해?',
questionTrans: 'What do you do on weekends?',
hint: '주말에 뭐 해? 라고 말해 봐!',
translation: 'What do you do on weekends?',
correction: '너는 주말에 주로 뭐 해?',
explanation: '조금 더 자연스러운 질문으로 ~',
answer: '재밌었겠다!',
question: '국어 시간 언제 시작 하더라?',
questionTrans: 'When does Korean class start?',
hint: 'Say a number in Korean',
translation: 'nine',
correction: '아홉시',
explanation:
'문장이 올바르게 표현되었습니다. 시간 표현이 명확하고 간결합니다.',
answer: '쉬는 시간 얼마 안남았네!',
},
{
question: '네가 제일 좋아하는 음식은 뭐야?',
questionTrans: 'What is your most favorite food?',
hint: '좋아하는 음식을 말해 봐!',
translation: 'What is your favorite food?',
correction: '네가 가장 좋아하는 음식은 뭐야?',
explanation: '"가장"이 더 자연스러운 표현입니다.',
answer: '맛있겠다!',
question: '혹시 숙제 다 했어?',
questionTrans: 'Did you do your homework?',
hint: "Try to say '응' or '아니'",
translation: 'Yeah, I finished all my homework.',
correction: '응 숙제 다 했어.',
explanation: '문장이 자연스럽고 맞습니다. 잘 하셨어요!',
answer: '대단해! 숙제를 다 했다니 정말 멋져!',
},
];

Expand Down
10 changes: 7 additions & 3 deletions app/main/my-learning/[level]/step3/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ export default function LevelStep3Page() {
</div>

<div className="grid grid-cols-3 gap-2">
{[1, 2, 3].map((item, idx) => (
{[
{ ko: '숙제', en: 'Homework' },
{ ko: '수업', en: 'Class' },
{ ko: '1교시', en: 'First class' },
].map((item, idx) => (
<div key={idx} className="bg-white rounded-2xl p-3">
<div className="flex flex-row items-center gap-2 mb-1">
<h3 className="text-black text-h3-bold">단어{item}</h3>
<h3 className="text-black text-h3-bold">{item.ko}</h3>
<Image
src={'/icons/bookmark-unchecked.svg'}
alt={'bookmark icon'}
Expand All @@ -92,7 +96,7 @@ export default function LevelStep3Page() {
/>
</div>
<div className="text-secondary-300 text-trans-cp2-regular">
단어 뜻 {item}
{item.en}
</div>
</div>
))}
Expand Down
28 changes: 22 additions & 6 deletions app/main/my-learning/_components/CharacterFrontText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use client';

import { FONT_CLASS } from '@/constants/languages';
import { useLanguageStore } from '@/stores/languageStore';
import { useVoiceStore } from '@/stores/voiceStore';
import { useTTS } from '@/hooks/useTTS';
import Image from 'next/image';

interface CharacterTextProps {
Expand All @@ -16,6 +20,13 @@ export default function CharacterFrontText({
image = '/character/default.webp', // 기본 이미지 URL
}: CharacterTextProps) {
const { currentLanguage } = useLanguageStore();
const { selectedVoice } = useVoiceStore();
const { playTTS, playing } = useTTS();

const handlePlayAudio = async () => {
if (!selectedVoice || playing) return;
await playTTS(title, selectedVoice.voiceName);
};

return (
<div className="flex flex-row w-full items-center-safe gap-2 mt-6">
Expand All @@ -30,13 +41,18 @@ export default function CharacterFrontText({
<h2 className="text-secondary-300 text-bd1-bold flex items-center justify-center gap-1">
{title}
{audio && (
<Image
src="/icons/audio.svg"
alt="audio icon"
width={20}
height={20}
<button
onClick={handlePlayAudio}
disabled={playing || !selectedVoice}
className="mb-1"
/>
>
<Image
src="/icons/audio.svg"
alt="audio icon"
width={20}
height={20}
/>
</button>
)}
</h2>
{/* 구분선 */}
Expand Down
Loading