diff --git a/.github/workflows/CI_Build_test.yml b/.github/workflows/CI_Build_test.yml index aa1f01a8a..2ef3f349d 100644 --- a/.github/workflows/CI_Build_test.yml +++ b/.github/workflows/CI_Build_test.yml @@ -43,4 +43,5 @@ jobs: KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: pnpm build diff --git "a/src/app/api/\bai/route.ts" "b/src/app/api/\bai/route.ts" deleted file mode 100644 index 3e4d23259..000000000 --- "a/src/app/api/\bai/route.ts" +++ /dev/null @@ -1,133 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -const url = 'https://jsonplaceholder.typicode.com/todos/1'; - -// 외부 api -/** - * GET 요청 함수 - * - */ -export const GET = async () => { - try { - const res = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'accessToken': 'bearer token', - }, - }); - const { data, error } = await res.json(); - - if (res.status !== 200) { - // return NextResponse.json({ message: error.message, status: res.status }); - throw new Error(JSON.stringify({ message: error.message, status: res.status })); - } - - return NextResponse.json({ status: 200, data }); - } catch (e) { - return NextResponse.json({ message: e, status: 503 }); - } -}; - -/** - * POST 요청 함수 - */ -export const POST = async (req: NextRequest) => { - try { - const body = await req.json(); - - const res = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'accessToken': 'bearer token', - }, - body: JSON.stringify(body), - }); - const { data, error } = await res.json(); - - if (res.status !== 200) { - return NextResponse.json({ message: error.message, status: res.status }); - } - - return NextResponse.json({ status: 200, data }); - } catch (e) { - return NextResponse.json({ message: e, status: 503 }); - } -}; - -/** - * DELETE 요청 함수 - */ -export const DELETE = async (req: NextRequest) => { - try { - const body = await req.json(); - - const res = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'accessToken': 'bearer token', - }, - body: JSON.stringify(body), - }); - const { data, error } = await res.json(); - - if (res.status !== 200) { - return NextResponse.json({ message: error.message, status: res.status }); - } - - return NextResponse.json({ status: 200, data }); - } catch (e) { - return NextResponse.json({ message: e, status: 503 }); - } -}; - -/** - * PATCH 요청 함수 - */ -export const PATCH = async (req: NextRequest) => { - try { - const body = await req.json(); - - const res = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'accessToken': 'bearer token', - }, - body: JSON.stringify(body), - }); - const { data, error } = await res.json(); - - if (res.status !== 200) { - return NextResponse.json({ message: error.message, status: res.status }); - } - - return NextResponse.json({ status: 200, data: data }); - } catch (e) { - return NextResponse.json({ message: e, status: 503 }); - } -}; - -/** - * PUT 요청 함수 - */ -export const PUT = async (req: NextRequest) => { - try { - const body = await req.json(); - - const res = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'accessToken': 'bearer token', - }, - body: JSON.stringify(body), - }); - const { data, error } = await res.json(); - - if (res.status !== 200) { - return NextResponse.json({ message: error.message, status: res.status }); - } - - return NextResponse.json({ status: 200, data }); - } catch (e) { - return NextResponse.json({ message: e, status: 503 }); - } -}; diff --git a/src/app/api/ai/stt/route.ts b/src/app/api/ai/stt/route.ts new file mode 100644 index 000000000..308407afa --- /dev/null +++ b/src/app/api/ai/stt/route.ts @@ -0,0 +1,43 @@ +import { ENV } from '@/constants/env-constants'; +import { AI_MESSAGE } from '@/constants/message-constants'; +import { NextRequest, NextResponse } from 'next/server'; +import OpenAI from 'openai'; + +const openAi = new OpenAI({ + apiKey: ENV.OPENAI_API_KEY, + dangerouslyAllowBrowser: true, +}); + +const FORMAT_FORMDATA = { + FILE: 'file', + MODEL: 'model', + LANGUAGE: 'language', +}; + +/** + * POST 요청 함수 + */ +export const POST = async (req: NextRequest): Promise => { + const { NOT_FILE, SERVER_ERROR } = AI_MESSAGE.STT; + const { FILE, MODEL, LANGUAGE } = FORMAT_FORMDATA; + try { + const formData = await req.formData(); + const file = formData.get(FILE) as File; + const model = formData.get(MODEL) as string; + const language = formData.get(LANGUAGE) as string; + + if (!file) { + return NextResponse.json({ message: NOT_FILE }, { status: 400 }); + } + + const { text } = await openAi.audio.transcriptions.create({ + file, + model, + language, + }); + + return NextResponse.json({ text }, { status: 200 }); + } catch (error) { + return NextResponse.json({ message: SERVER_ERROR }, { status: 503 }); + } +}; diff --git a/src/app/api/ai/tts/route.ts b/src/app/api/ai/tts/route.ts new file mode 100644 index 000000000..ec38a88f3 --- /dev/null +++ b/src/app/api/ai/tts/route.ts @@ -0,0 +1,42 @@ +import { ENV } from '@/constants/env-constants'; +import { AI_MESSAGE } from '@/constants/message-constants'; +import { NextRequest, NextResponse } from 'next/server'; +import OpenAI from 'openai'; + +const openAi = new OpenAI({ + apiKey: ENV.OPENAI_API_KEY, + dangerouslyAllowBrowser: true, +}); + +/** + * POST 요청 함수 + */ +export const POST = async (req: NextRequest): Promise => { + const { text, model, voice, speed, response_format, instructions } = await req.json(); + const { REQUEST_FAILURE, SERVER_ERROR } = AI_MESSAGE.TTS; + try { + const res = await openAi.audio.speech.create({ + input: text, + model, + response_format, + voice, + speed, + instructions, + }); + + if (!res.ok) { + const error_message = await res.text(); + console.error(error_message); + return NextResponse.json({ message: REQUEST_FAILURE }, { status: res.status }); + } + + const arrayBuffer = await res.arrayBuffer(); + const base64Audio = Buffer.from(arrayBuffer).toString('base64'); + const audioUrl = `data:audio/${response_format};base64,${base64Audio}`; + + return NextResponse.json({ audioUrl }, { status: 200 }); + } catch (error) { + console.error(error); + return NextResponse.json({ message: SERVER_ERROR }, { status: 503 }); + } +}; diff --git a/src/constants/api-method-constants.ts b/src/constants/api-method-constants.ts new file mode 100644 index 000000000..17399190d --- /dev/null +++ b/src/constants/api-method-constants.ts @@ -0,0 +1,7 @@ +export const API_METHOD = { + POST: 'POST', + GET: 'GET', + DELETE: 'DELETE', + PUT: 'PUT', + PATCH: 'PATCH', +}; diff --git a/src/constants/env-constants.ts b/src/constants/env-constants.ts index 534be8d66..58e68cd95 100644 --- a/src/constants/env-constants.ts +++ b/src/constants/env-constants.ts @@ -14,7 +14,8 @@ export const ENV = { NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - OPENAI_API_KEY: process.env.NEXT_PUBLIC_OPENAI_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, - SENTRY_DSN: process.env.SENTRY_DSN + SENTRY_DSN: process.env.SENTRY_DSN, + SENTRY_CLIENT_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, }; diff --git a/src/constants/interview-constants.ts b/src/constants/interview-constants.ts new file mode 100644 index 000000000..dd62fd160 --- /dev/null +++ b/src/constants/interview-constants.ts @@ -0,0 +1,4 @@ +export const INTERVIEW_TYPE = { + PRESSURE: 'pressure', + CALM: 'calm', +}; diff --git a/src/constants/message-constants.ts b/src/constants/message-constants.ts index 5d38e2598..bae4e7abc 100644 --- a/src/constants/message-constants.ts +++ b/src/constants/message-constants.ts @@ -6,7 +6,7 @@ export const AUTH_MESSAGE = { PASSWORD_LENGTH: '비밀번호는 6자 이상이어야 합니다.', PASSWORD_SPECIAL_CHAR: '비밀번호는 최소 하나의 특수 문자를 포함해야 합니다.', EMAIL_EMPTY_FIELD: '이메일을 입력해주세요.', - PASSWORD_EMPTY_FIELD: '비밀번호를 입력해주세요.' + PASSWORD_EMPTY_FIELD: '비밀번호를 입력해주세요.', }, RESULT: { SIGN_UP_SUCCESS: '회원 가입에 성공했습니다.', @@ -14,6 +14,17 @@ export const AUTH_MESSAGE = { SIGN_UP_EXIST_ERROR: '이미 존재하는 이메일입니다.', SIGN_UP_EMPTY_FIELD: '모든 값을 입력해주세요.', SIGN_IN_SUCCESS: '로그인에 성공했습니다.', - SIGN_IN_FAILED: '이메일 혹은 비밀번호를 확인해주세요.' + SIGN_IN_FAILED: '이메일 혹은 비밀번호를 확인해주세요.', + }, +}; + +export const AI_MESSAGE = { + TTS: { + REQUEST_FAILURE: 'TTS 요청 실패', + SERVER_ERROR: 'TTS 서버 에러', + }, + STT: { + SERVER_ERROR: 'STT 서버 에러', + NOT_FILE: '파일이 제공되지 않았습니다.', }, }; diff --git a/src/constants/path-constant.ts b/src/constants/path-constant.ts index 9477ed6be..febdc84e2 100644 --- a/src/constants/path-constant.ts +++ b/src/constants/path-constant.ts @@ -13,4 +13,8 @@ export const ROUTE_HANDLER_PATH = { AUTH: { SIGN_UP: '/api/sign-up', }, + AI: { + STT: '/api/ai/stt', + TTS: '/api/ai/tts', + }, }; diff --git a/src/features/interview/api/client-services.ts b/src/features/interview/api/client-services.ts new file mode 100644 index 000000000..92c6840fe --- /dev/null +++ b/src/features/interview/api/client-services.ts @@ -0,0 +1,102 @@ +import { API_METHOD } from '@/constants/api-method-constants'; +import { ROUTE_HANDLER_PATH } from '@/constants/path-constant'; +import { INTERVIEW_TYPE } from '@/constants/interview-constants'; +import { fetchWithSentry } from '@/utils/fetch-with-sentry'; + +const { TTS, STT } = ROUTE_HANDLER_PATH.AI; +const { POST } = API_METHOD; + +const TTS_DEFAULT_OPTIONS = { + MODEL: 'gpt-4o-mini-tts', + FORMAT: 'mp3', +}; + +const STT_DEFAULT_OPTIONS = { + MODEL: 'gpt-4o-transcribe', + FORMAT: 'webm', + LANGUAGE: 'ko', +}; + +const CALM_OPTIONS = { + VOICE: 'ash', + SPEED: 1, + INSTRUCTION: `Uses a friendly and gentle tone of voice. + Rather than challenging the candidate's answers, frequently provides emotional empathy or positive reactions.`, +}; + +const PRESSURE_OPTIONS = { + VOICE: 'sage', + SPEED: 2.5, + INSTRUCTION: `Uses a firm and dry tone of voice. +Avoids showing emotional empathy or positive reactions to the candidate's responses.`, +}; + +/** + * @function textToSpeech + * @param text - 서버로부터 받아온 텍스트 입력 + * @param model - 고정된 TTS 모델 ('gpt-4o-mini-tts') + * @param voice - default = calm('ash'), pressure('sage') + * @param speed - default = calm(1), pressure(2.5) + * @param response_format - format 형식 default = 'mp3' + * @param instruction - 목소리 형식 default = calm + * @returns {void} + */ + +type TTS_Props = { + text: string; + type: string; +}; + +export const textToSpeech = async ({ text, type }: TTS_Props): Promise => { + const { MODEL, FORMAT } = TTS_DEFAULT_OPTIONS; + const { PRESSURE } = INTERVIEW_TYPE; + const { VOICE, SPEED, INSTRUCTION } = type === PRESSURE ? PRESSURE_OPTIONS : CALM_OPTIONS; + + const res = await fetchWithSentry(TTS, { + method: POST, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + model: MODEL, + response_format: FORMAT, + voice: VOICE, + speed: SPEED, + instruction: INSTRUCTION, + }), + }); + + const audio = new Audio(res.audioUrl); + await audio.play(); +}; + +/** + * @function speechToText + * @param blob - 사용자 목소리 파일 + * @param model - default('whisper-1') + * @param format - default('webm') + * @param language - default('ko') + * @returns {text} + */ + +type STT_Props = { + blob: Blob; +}; + +export const speechToText = async ({ blob }: STT_Props): Promise => { + const { MODEL, FORMAT, LANGUAGE } = STT_DEFAULT_OPTIONS; + + const formData = new FormData(); + + const file = new File([blob], 'recording.webm', { type: `audio/${FORMAT}` }); + formData.append('file', file); + formData.append('model', MODEL); + formData.append('language', LANGUAGE); + formData.append('format', FORMAT); + + const res = await fetchWithSentry(STT, { + method: POST, + body: formData, + }); + + return res.text; +}; diff --git a/src/features/interview/stt-component.tsx b/src/features/interview/stt-component.tsx new file mode 100644 index 000000000..dde8a869c --- /dev/null +++ b/src/features/interview/stt-component.tsx @@ -0,0 +1,26 @@ +import { speechToText } from '@/features/interview/api/client-services'; +import { useState } from 'react'; + +type Props = { + blob: Blob; +}; + +const STTComponent = ({ blob }: Props) => { + const [voiceText, setVoiceText] = useState(''); + const handleClick = async () => { + try { + const text = await speechToText({ blob }); + setVoiceText(text); + } catch (error) { + // TODO : ERROR 처리 + alert(error.message); + } + }; + return ( +
+ +
+ ); +}; + +export default STTComponent; diff --git a/src/features/interview/tts-component.tsx b/src/features/interview/tts-component.tsx new file mode 100644 index 000000000..833533d88 --- /dev/null +++ b/src/features/interview/tts-component.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { INTERVIEW_TYPE } from '@/constants/interview-constants'; +import { textToSpeech } from '@/features/interview/api/client-services'; + +const TTSComponent = () => { + const { CALM, PRESSURE } = INTERVIEW_TYPE; + const handleClick = async () => { + try { + await textToSpeech({ + text: '최창연 씨, 탐색 시간을 정의하는 방법과 사용자 테스트를 통해 결과를 도출한 방식은 이해했습니다. 그러나, 사용하신 사용자 테스트의 샘플 크기와 방법론은 무엇이었는지 명확하게 설명해 주셔야 합니다. 재방문율의 증가는 긍정적인 신호이나, 그 수치가 통계적으로 유의미한지 여부는 어느 정도였는지에 대한 자료를 제시해 주실 수 있습니까? 그리고 정보가 직관적이다는 피드백을 수집한 방법에 대해서도 구체적으로 말씀해 주십시오.', + type: PRESSURE, + }); + } catch (error) { + // TODO : ERROR 처리 + alert(error.message); + } + }; + return ( +
+ +
+ ); +}; + +export default TTSComponent; diff --git a/src/features/interview/voice-input-button.tsx b/src/features/interview/voice-input-button.tsx index 01ff8316d..12f818c73 100644 --- a/src/features/interview/voice-input-button.tsx +++ b/src/features/interview/voice-input-button.tsx @@ -1,6 +1,7 @@ 'use client'; import { useAudioRecorder } from '@/features/interview/hooks/use-audio-recorder'; +import STTComponent from '@/features/interview/stt-component'; const VoiceInputButton = () => { const { isRecording, audioBlob, startRecording, stopRecording } = useAudioRecorder(); @@ -11,6 +12,7 @@ const VoiceInputButton = () => { {isRecording ? '답변 완료하기' : '답변하기'} {audioBlob &&