Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c0b355c
feat:#18 TTS Component
choichangyeon Apr 2, 2025
a68a76a
feat:#18 TTS api 정의
choichangyeon Apr 2, 2025
1b0376a
feat:#18 TTS RouteHandler 적용
choichangyeon Apr 2, 2025
4b6cde1
feat:#18 test component 생성
choichangyeon Apr 2, 2025
a5bd41f
feat:#18 Route Handler 요청 구현
choichangyeon Apr 2, 2025
74bd73f
feat:#18 type 추가
choichangyeon Apr 2, 2025
4dfe614
feat:#18 라이브러리 사용
choichangyeon Apr 2, 2025
bdbd26a
feat:#18 Options 설정
choichangyeon Apr 2, 2025
9730372
refactor:#18 구조분해 할당 진행
choichangyeon Apr 3, 2025
6ebb0c5
rename:#18 변수명 수정
choichangyeon Apr 3, 2025
282cd1c
refactor:#18 로직 수정
choichangyeon Apr 3, 2025
2113c72
rename:#18 Page명 수정
choichangyeon Apr 3, 2025
3b77ead
fix:#18 함수 수정
choichangyeon Apr 3, 2025
97f8789
rename:#18 컴포넌트명 변경
choichangyeon Apr 4, 2025
867596c
rename:#18 함수명 변경
choichangyeon Apr 4, 2025
e6a2947
rename:#18 함수명 수정
choichangyeon Apr 4, 2025
93c6119
feat:#18 stt component
choichangyeon Apr 4, 2025
3dbf29d
move:#18 path 변경
choichangyeon Apr 4, 2025
27c5800
feat:#18 stt api 연결
choichangyeon Apr 4, 2025
e00617a
feat:#18 stt component 추가
choichangyeon Apr 4, 2025
edb4e52
feat:#18 컴포넌트 추가
choichangyeon Apr 4, 2025
83d897c
feat:#18 stt 컴포넌트 추가
choichangyeon Apr 4, 2025
2bed119
feat:#18 STT api 연결 및 기능 구현
choichangyeon Apr 4, 2025
fdcd48e
feat:#18 RouteHandler 상수화
choichangyeon Apr 4, 2025
f08fc5f
feat:#18 PATH 추가
choichangyeon Apr 4, 2025
f736ab9
feat:#18 API method 상수화
choichangyeon Apr 4, 2025
b21afff
refactor:#18 stt 컴포넌트 수정
choichangyeon Apr 4, 2025
86a13db
move:#18 폴더 수정 및 삭제
choichangyeon Apr 4, 2025
b6e2295
refactor:#18 불필요한 파일 삭제
choichangyeon Apr 4, 2025
2e04aa8
feat:#18 status 코드 수정
choichangyeon Apr 4, 2025
04d3d8a
rename:#18 변수명 변경
choichangyeon Apr 4, 2025
71fdd55
refactor:#18 CI 파일 수정
choichangyeon Apr 4, 2025
aefb677
feat:#18 인터뷰 타입 생성
choichangyeon Apr 4, 2025
de4912d
refactor:#18 인터뷰 타입 적용
choichangyeon Apr 4, 2025
756ec36
refactor:#18 상수화 및 객체구조할당
choichangyeon Apr 4, 2025
de5c680
refactor:#18 상수화
choichangyeon Apr 5, 2025
9cd5e85
refactor:#18 route handler status 수정
choichangyeon Apr 6, 2025
cbdaa7f
feat:#18 메시지 상수화
choichangyeon Apr 6, 2025
f45539c
refactor:#18 상수화 import
choichangyeon Apr 6, 2025
7365f11
refactor:#18 에러 처리 변경
choichangyeon Apr 6, 2025
1a636e1
refactor:#18 메시지 수정
choichangyeon Apr 6, 2025
45403e0
feat:#18 Sentry 적용
choichangyeon Apr 6, 2025
ed24a97
refactor:#18 setText 수정
choichangyeon Apr 6, 2025
d20b09d
refactor:#18 return type 설정
choichangyeon Apr 6, 2025
a6b41cd
refactor:#18 sentry 수정
choichangyeon Apr 6, 2025
5d04fa4
refactor:#18 return 수정
choichangyeon Apr 6, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/CI_Build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
133 changes: 0 additions & 133 deletions src/app/api/ai/route.ts

This file was deleted.

43 changes: 43 additions & 0 deletions src/app/api/ai/stt/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> => {
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 });
}
};
42 changes: 42 additions & 0 deletions src/app/api/ai/tts/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> => {
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 });
}
};
7 changes: 7 additions & 0 deletions src/constants/api-method-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const API_METHOD = {
POST: 'POST',
GET: 'GET',
DELETE: 'DELETE',
PUT: 'PUT',
PATCH: 'PATCH',
};
5 changes: 3 additions & 2 deletions src/constants/env-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
4 changes: 4 additions & 0 deletions src/constants/interview-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const INTERVIEW_TYPE = {
PRESSURE: 'pressure',
CALM: 'calm',
};
15 changes: 13 additions & 2 deletions src/constants/message-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,25 @@ export const AUTH_MESSAGE = {
PASSWORD_LENGTH: '비밀번호는 6자 이상이어야 합니다.',
PASSWORD_SPECIAL_CHAR: '비밀번호는 최소 하나의 특수 문자를 포함해야 합니다.',
EMAIL_EMPTY_FIELD: '이메일을 입력해주세요.',
PASSWORD_EMPTY_FIELD: '비밀번호를 입력해주세요.'
PASSWORD_EMPTY_FIELD: '비밀번호를 입력해주세요.',
},
RESULT: {
SIGN_UP_SUCCESS: '회원 가입에 성공했습니다.',
SIGN_UP_FAILED: '회원 가입에 실패했습니다.',
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: '파일이 제공되지 않았습니다.',
},
};
4 changes: 4 additions & 0 deletions src/constants/path-constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ export const ROUTE_HANDLER_PATH = {
AUTH: {
SIGN_UP: '/api/sign-up',
},
AI: {
STT: '/api/ai/stt',
TTS: '/api/ai/tts',
},
};
102 changes: 102 additions & 0 deletions src/features/interview/api/client-services.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<string> => {
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;
};
Loading