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
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
34 changes: 25 additions & 9 deletions app/main/my-learning/_components/step1/PhrasePracticeText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use client';

import Image from 'next/image';
import { useLanguageStore } from '@/stores/languageStore';
import { useVoiceStore } from '@/stores/voiceStore';
import { useTTS } from '@/hooks/useTTS';
import { useState } from 'react';
import {
RATING_THRESHOLDS,
Expand Down Expand Up @@ -32,6 +36,8 @@ export default function PhrasePracticeText({
evaluationType,
}: PhrasePracticeTextProps) {
const { currentLanguage } = useLanguageStore();
const { selectedVoice } = useVoiceStore();
const { playTTS, playing } = useTTS();
const [isBookmarked, setIsBookmarked] = useState(false);

const typedWords = inputText.trim() === '' ? [] : inputText.trim().split(' ');
Expand Down Expand Up @@ -86,6 +92,11 @@ export default function PhrasePracticeText({
);
});

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

return (
<div className={`flex flex-col ${className}`}>
<div className="px-4 py-4.5 rounded-3xl bg-bg-solid">
Expand All @@ -96,15 +107,20 @@ export default function PhrasePracticeText({
<h2 className="text-h2-bold text-black flex flex-row gap-2">
{renderHighlightedPhrase()}
</h2>
<Image
src={'/icons/audio-blue.svg'}
alt={'listen'}
width={16}
height={16}
className={
typedWords.length > 0 && showEvaluation ? 'pb-5.5' : 'pb-1.5'
}
/>
<button
onClick={handlePlayAudio}
disabled={playing || !selectedVoice}
>
<Image
src={'/icons/audio-blue.svg'}
alt={'listen'}
width={16}
height={16}
className={
typedWords.length > 0 && showEvaluation ? 'pb-5.5' : 'pb-1.5'
}
/>
</button>
<button
onClick={() => setIsBookmarked(prev => !prev)}
className="focus:outline-none"
Expand Down
26 changes: 20 additions & 6 deletions app/main/my-learning/_components/step2/chat/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import Image from 'next/image';
import { useState } from 'react';
import { Role } from './ChatMessageList';
import { useVoiceStore } from '@/stores/voiceStore';
import { useTTS } from '@/hooks/useTTS';

export default function ChatBubble({
role,
Expand All @@ -18,6 +20,13 @@ export default function ChatBubble({
showTranslate?: boolean;
}) {
const [showTranslation, setShowTranslation] = useState(false);
const { selectedVoice } = useVoiceStore();
const { playTTS, playing } = useTTS();

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

return (
<div className={`flex items-end gap-2`}>
Expand All @@ -41,12 +50,17 @@ export default function ChatBubble({
{(showAudio || showTranslate) && (
<div className="pt-1 flex items-center gap-2">
{showAudio && (
<Image
src="/icons/audio-gray.svg"
alt="listen"
width={16}
height={16}
/>
<button
onClick={handlePlayAudio}
disabled={playing || !selectedVoice}
>
<Image
src="/icons/audio-gray.svg"
alt="listen"
width={16}
height={16}
/>
</button>
)}
{showTranslate && translation && (
<button onClick={() => setShowTranslation(prev => !prev)}>
Expand Down
Loading