From 0c09b6c943212ce98e254b6faf106c9cea459c95 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 14 Jun 2026 14:17:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(interview):=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EB=85=B9=EC=9D=8C=20=EC=A4=91=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=A0=88=EB=B2=A8=20=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?+=20=ED=83=80=EC=9D=B4=EB=A8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 녹음 중 정적인 빨간 점·문구만 있던 답변 입력 바에 실제 마이크 입력 피드백을 추가해 '답변하는 쪽' 음성 경험을 질문측과 맞춤. - useVoiceRecorder 가 시각화용 MediaStream 을 노출 - MicLevelMeter: AnalyserNode 주파수 데이터를 막대 높이로 실시간 반영 (프레임마다 ref 로 DOM transform 직접 갱신, 리렌더 회피) - AnswerComposer: 녹음 경과 시간(MM:SS) + 레벨 미터 표시, 마이크 권한 대기 중엔 "마이크 준비 중…" - 타이머 리셋은 시작 이벤트에서 처리(effect 내 동기 setState 회피) Co-Authored-By: Claude Opus 4.8 --- .../interview/lib/media/useVoiceRecorder.ts | 6 +- .../interview/ui/live/AnswerComposer.tsx | 58 ++++++++++++++----- .../interview/ui/live/MicLevelMeter.tsx | 58 +++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 frontend/src/features/interview/ui/live/MicLevelMeter.tsx 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 && (