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
6 changes: 6 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,12 @@
},
"targetEvidence" : {
"type" : "string"
},
"ttsAudioUrl" : {
"type" : "string"
},
"audioFileUrl" : {
"type" : "string"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.session.application.dto.MessageResult;
import com.stackup.stackup.session.application.event.AnswerSubmittedEvent;
import com.stackup.stackup.session.domain.InterviewMessage;
Expand All @@ -11,8 +12,13 @@
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.MessageStatus;
import com.stackup.stackup.session.domain.SessionStatus;
import com.stackup.stackup.session.domain.TtsStatus;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -24,17 +30,43 @@
@Transactional(readOnly = true)
public class InterviewMessageService {

private static final Logger log = LoggerFactory.getLogger(InterviewMessageService.class);
private static final Duration AUDIO_URL_TTL = Duration.ofMinutes(30);

private final InterviewSessionRepository sessionRepository;
private final InterviewMessageRepository messageRepository;
private final ObjectStorageClient storage;
private final ApplicationEventPublisher events;

public List<MessageResult> list(Long userId, Long sessionId) {
ownedSession(userId, sessionId);
return messageRepository.findBySession_IdOrderBySequenceNumberAsc(sessionId).stream()
.map(MessageResult::of)
.map(this::toResultWithAudioUrls)
.toList();
}

// 재생용 presigned URL 동봉: 질문 TTS(SUCCEEDED) + 음성 답변 원본.
// presign 실패가 메시지 조회 전체를 깨뜨리지 않도록 개별 try/catch.
private MessageResult toResultWithAudioUrls(InterviewMessage m) {
String ttsUrl = m.getTtsStatus() == TtsStatus.SUCCEEDED
? presign(m.getTtsAudioPath()) : null;
String audioUrl = presign(m.getAudioFilePath());
return MessageResult.of(m, ttsUrl, audioUrl);
}

private String presign(String key) {
if (key == null || key.isBlank()) {
return null;
}
try {
URI url = storage.createPresignedGetUrl(key, AUDIO_URL_TTL);
return url == null ? null : url.toString();
} catch (RuntimeException e) {
log.warn("presigned audio URL creation failed. key={}", key, e);
return null;
}
}

@Transactional
public MessageResult submitAnswer(Long userId, Long sessionId, String content, String idempotencyKey) {
InterviewSession session = ownedSession(userId, sessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ public record MessageResult(
MessageStatus status,
Instant createdAt,
String category,
String targetEvidence
String targetEvidence,
// 재생용 presigned URL. 조회(list) 경로에서만 채우고, 그 외에는 null.
String ttsAudioUrl,
String audioFileUrl
// expectedSignal 은 의도적으로 제외 — 정답 유출 방지(라이브 비노출).
) {
public static MessageResult of(InterviewMessage m) {
return of(m, null, null);
}

public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String audioFileUrl) {
return new MessageResult(
m.getId(),
m.getSession().getId(),
Expand All @@ -38,7 +45,9 @@ public static MessageResult of(InterviewMessage m) {
m.getStatus(),
m.getCreatedAt(),
m.getCategory(),
m.getTargetEvidence()
m.getTargetEvidence(),
ttsAudioUrl,
audioFileUrl
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public record MessageResponse(
MessageStatus status,
Instant createdAt,
String category,
String targetEvidence
String targetEvidence,
// 재생용 presigned URL (질문 TTS / 음성 답변 원본). 조회 응답에서만 채움.
String ttsAudioUrl,
String audioFileUrl
// expectedSignal 은 노출하지 않음 — 자기연습 시 정답 유출 방지.
) {
public static MessageResponse from(MessageResult r) {
Expand All @@ -38,7 +41,9 @@ public static MessageResponse from(MessageResult r) {
r.status(),
r.createdAt(),
r.category(),
r.targetEvidence()
r.targetEvidence(),
r.ttsAudioUrl(),
r.audioFileUrl()
);
}
}
10 changes: 9 additions & 1 deletion frontend/src/domain/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,13 @@ export type {
JobCategory,
MessageRole,
} from './model/types'
export { isQuestion, isAnswer, currentTurn, canSubmitAnswer, sessionProgress } from './lib/turn'
export {
isQuestion,
isAnswer,
isTranscribing,
VOICE_TRANSCRIBING_TEXT,
currentTurn,
canSubmitAnswer,
sessionProgress,
} from './lib/turn'
export type { Turn } from './lib/turn'
11 changes: 11 additions & 0 deletions frontend/src/domain/session/lib/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ export function isAnswer(message: Message): boolean {
return message.role === 'INTERVIEWEE'
}

// 음성 답변 placeholder 의 임시 content (백엔드 InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT 와 동일).
// STT 완료 전까지 이 값이 들어 있고, 완료되면 실제 transcript 로 교체된다.
export const VOICE_TRANSCRIBING_TEXT = '(transcribing)'

// STT 대기 중(transcript 아직 없음)인 음성 답변인지.
export function isTranscribing(message: Message): boolean {
if (!isAnswer(message)) return false
const c = (message.content ?? '').trim()
return c === '' || c === VOICE_TRANSCRIBING_TEXT
}

export function currentTurn(messages: Message[]): Turn {
const last = messages[messages.length - 1]
// 마지막이 '내용이 있는' 면접관 질문일 때만 답변 차례. 질문 생성 중(빈 content)
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/features/interview/api/messageApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,26 @@ type S = components['schemas']
export async function listMessages(sessionId: number): Promise<S['MessageResponse'][]> {
return (await apiClient.get<S['MessageResponse'][]>(`/api/sessions/${sessionId}/messages`)).data
}

// 음성 답변 업로드(multipart). 응답은 transcribing placeholder 메시지.
// STT 완료 후 SESSION_MESSAGE SSE 로 transcript 가 도착한다.
export async function submitVoiceAnswer(
sessionId: number,
audio: Blob,
idempotencyKey?: string,
): Promise<S['MessageResponse']> {
const form = new FormData()
const ext = audio.type.includes('ogg') ? 'ogg' : 'webm'
form.append('audio', audio, `answer.${ext}`)
const { data } = await apiClient.post<S['MessageResponse']>(
`/api/sessions/${sessionId}/messages/voice`,
form,
{
headers: {
'Content-Type': 'multipart/form-data',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
},
)
return data
}
90 changes: 90 additions & 0 deletions frontend/src/features/interview/lib/media/useVoiceRecorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react'

export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'denied' | 'unsupported'

// MediaRecorder 가 만들 수 있는 후보. 백엔드 허용 목록(webm/ogg/mpeg/mp4/wav)과 교집합.
const PREFERRED_MIME = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']

function pickMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
return PREFERRED_MIME.find((t) => MediaRecorder.isTypeSupported(t))
}

// 라이브 면접 음성 답변 녹음. start → 마이크 권한 요청 + 녹음, stop → 오디오 Blob 반환.
// 권한 거부/미지원 시 status 로 알려서 호출부가 텍스트 입력으로 fallback 한다.
export function useVoiceRecorder() {
const [status, setStatus] = useState<RecorderStatus>(() =>
typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia
? 'unsupported'
: 'idle',
)

const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])

const cleanup = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
recorderRef.current = null
chunksRef.current = []
}, [])

useEffect(() => cleanup, [cleanup])

const start = useCallback(async (): Promise<boolean> => {
if (status === 'unsupported' || status === 'recording') return false
setStatus('requesting')
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch {
setStatus('denied')
return false
}
const mimeType = pickMimeType()
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
chunksRef.current = []
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data)
}
recorder.start()
streamRef.current = stream
recorderRef.current = recorder
setStatus('recording')
return true
}, [status])

// 녹음 중지 후 Blob 반환. 녹음 중이 아니면 null.
const stop = useCallback((): Promise<Blob | null> => {
const recorder = recorderRef.current
if (!recorder || recorder.state === 'inactive') {
cleanup()
setStatus('idle')
return Promise.resolve(null)
}
return new Promise((resolve) => {
recorder.onstop = () => {
const type = recorder.mimeType || chunksRef.current[0]?.type || 'audio/webm'
const blob = new Blob(chunksRef.current, { type })
cleanup()
setStatus('idle')
resolve(blob.size > 0 ? blob : null)
}
recorder.stop()
})
}, [cleanup])

// 녹음 폐기(업로드 안 함).
const cancel = useCallback(() => {
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = null
recorder.stop()
}
cleanup()
setStatus('idle')
}, [cleanup])

return { status, start, stop, cancel }
}
20 changes: 19 additions & 1 deletion frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { currentTurn } from '@/domain/session'
import type { Message } from '@/domain/session'
import { submitVoiceAnswer } from '../api/messageApi'
import { sessionKeys, useSession } from './useSession'
import { messageKeys, useSessionMessages } from './useSessionMessages'
import { useSessionLifecycle } from './useSessionLifecycle'
Expand Down Expand Up @@ -68,13 +69,30 @@ export function useLiveInterview(sessionId: number) {
[socketSubmit],
)

// 음성 답변은 REST 업로드(multipart). 성공 시 placeholder 메시지를
// 받으므로 목록을 무효화해 "음성 인식 중…" 버블을 띄운다.
// 이후 STT 완료는 callback.voice → SESSION_MESSAGE SSE 로 갱신된다.
const voiceMutation = useMutation({
mutationFn: (audio: Blob) => submitVoiceAnswer(sessionId, audio, crypto.randomUUID()),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }),
})

const submitVoice = useCallback(
(audio: Blob) => voiceMutation.mutate(audio),
[voiceMutation],
)

return {
session: sessionQuery.data,
status,
items,
turn: currentTurn(items),
connection,
submitAnswer,
submitVoice,
voiceUploading: voiceMutation.isPending,
voiceError: voiceMutation.isError,
endSession: () => end.mutate(),
isLoading: sessionQuery.isLoading,
}
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/features/interview/ui/live/AnswerBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { isTranscribing } from '@/domain/session'
import type { Message } from '@/domain/session'

export function AnswerBubble({ message }: { message: Message }) {
const transcribing = isTranscribing(message)
const failed = transcribing && message.status === 'FAILED'

return (
<div className="flex justify-end">
<div className="flex flex-col items-end gap-1">
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg rounded-tr-sm bg-primary px-4 py-3 text-body text-fg-on-primary shadow-sm">
{message.content}
{!transcribing ? (
message.content
) : (
<span className="inline-flex items-center gap-2 text-fg-on-primary/80">
{failed ? (
'음성 인식에 실패했어요. 다시 답변해 주세요.'
) : (
<>
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-fg-on-primary" />
음성 인식 중…
</>
)}
</span>
)}
</div>
{message.audioFileUrl && (
<audio controls src={message.audioFileUrl} className="max-w-[80%]" preload="none" />
)}
</div>
)
}
Loading