diff --git a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts index f4a1bde2..9414ce75 100644 --- a/frontend/src/features/interview/lib/media/useVoiceRecorder.ts +++ b/frontend/src/features/interview/lib/media/useVoiceRecorder.ts @@ -22,12 +22,15 @@ export function useVoiceRecorder() { const recorderRef = useRef(null) const streamRef = useRef(null) const chunksRef = useRef([]) + // 녹음 중 실시간 레벨 미터(MicLevelMeter)가 구독할 수 있도록 스트림을 상태로도 노출. + const [stream, setStream] = useState(null) const cleanup = useCallback(() => { streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current = null recorderRef.current = null chunksRef.current = [] + setStream(null) }, []) useEffect(() => cleanup, [cleanup]) @@ -51,6 +54,7 @@ export function useVoiceRecorder() { recorder.start() streamRef.current = stream recorderRef.current = recorder + setStream(stream) setStatus('recording') return true }, [status]) @@ -86,5 +90,5 @@ export function useVoiceRecorder() { setStatus('idle') }, [cleanup]) - return { status, start, stop, cancel } + return { status, stream, start, stop, cancel } } diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.tsx index 57294dec..7d628548 100644 --- a/frontend/src/features/interview/ui/live/AnswerComposer.tsx +++ b/frontend/src/features/interview/ui/live/AnswerComposer.tsx @@ -1,8 +1,15 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { KeyboardEvent } from 'react' import { TextArea } from '@/shared/ui/TextArea' import { Button } from '@/shared/ui/Button' import { useVoiceRecorder } from '../../lib/media/useVoiceRecorder' +import { MicLevelMeter } from './MicLevelMeter' + +function formatElapsed(sec: number): string { + const m = Math.floor(sec / 60) + const s = sec % 60 + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +} function MicIcon() { return ( @@ -37,10 +44,24 @@ export function AnswerComposer({ voiceUploading?: boolean }) { const [value, setValue] = useState('') - const { status: recStatus, start, stop, cancel } = useVoiceRecorder() + const { status: recStatus, stream, start, stop, cancel } = useVoiceRecorder() const recording = recStatus === 'recording' || recStatus === 'requesting' const voiceSupported = recStatus !== 'unsupported' && Boolean(onSubmitVoice) + // 녹음 경과 시간(초). recording 상태에서만 흐른다. 리셋은 시작 시점(이벤트)에서. + const [elapsed, setElapsed] = useState(0) + useEffect(() => { + if (recStatus !== 'recording') return + const startedAt = Date.now() + const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 250) + return () => clearInterval(id) + }, [recStatus]) + + const beginRecording = () => { + setElapsed(0) + void start() + } + const submit = () => { const trimmed = value.trim() if (!trimmed || disabled || submitLocked) return @@ -62,20 +83,29 @@ export function AnswerComposer({ // 녹음/업로드 중에는 전용 바를 보여준다. if (recording || voiceUploading) { + const requesting = recStatus === 'requesting' return (
- - {voiceUploading ? ( - '음성 답변 업로드 중…' - ) : ( - <> + {voiceUploading ? ( + 음성 답변 업로드 중… + ) : requesting ? ( + 마이크 준비 중… + ) : ( + + - 녹음 중… 답변이 끝나면 전송을 누르세요 - - )} - - {!voiceUploading && ( -
+ + {formatElapsed(elapsed)} + + + + + 답변이 끝나면 전송을 누르세요 + + + )} + {!voiceUploading && !requesting && ( +
@@ -107,7 +137,7 @@ export function AnswerComposer({ {voiceSupported && (