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
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ export function useVoiceRecorder() {
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
// 녹음 중 실시간 레벨 미터(MicLevelMeter)가 구독할 수 있도록 스트림을 상태로도 노출.
const [stream, setStream] = useState<MediaStream | null>(null)

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

useEffect(() => cleanup, [cleanup])
Expand All @@ -51,6 +54,7 @@ export function useVoiceRecorder() {
recorder.start()
streamRef.current = stream
recorderRef.current = recorder
setStream(stream)
setStatus('recording')
return true
}, [status])
Expand Down Expand Up @@ -86,5 +90,5 @@ export function useVoiceRecorder() {
setStatus('idle')
}, [cleanup])

return { status, start, stop, cancel }
return { status, stream, start, stop, cancel }
}
58 changes: 44 additions & 14 deletions frontend/src/features/interview/ui/live/AnswerComposer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -62,20 +83,29 @@ export function AnswerComposer({

// 녹음/업로드 중에는 전용 바를 보여준다.
if (recording || voiceUploading) {
const requesting = recStatus === 'requesting'
return (
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-raised px-4 py-3">
<span className="flex items-center gap-2 text-body text-fg">
{voiceUploading ? (
'음성 답변 업로드 중…'
) : (
<>
{voiceUploading ? (
<span className="text-body text-fg">음성 답변 업로드 중…</span>
) : requesting ? (
<span className="text-body text-fg-muted">마이크 준비 중…</span>
) : (
<span className="flex min-w-0 items-center gap-3 text-body text-fg">
<span className="flex items-center gap-2">
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-danger" />
녹음 중… 답변이 끝나면 전송을 누르세요
</>
)}
</span>
{!voiceUploading && (
<div className="flex items-center gap-2">
<span className="font-medium tabular-nums" aria-label={`녹음 중 ${formatElapsed(elapsed)}`}>
{formatElapsed(elapsed)}
</span>
</span>
<MicLevelMeter stream={stream} />
<span className="hidden truncate text-caption text-fg-muted sm:inline">
답변이 끝나면 전송을 누르세요
</span>
</span>
)}
{!voiceUploading && !requesting && (
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" onClick={cancel}>
취소
</Button>
Expand Down Expand Up @@ -107,7 +137,7 @@ export function AnswerComposer({
{voiceSupported && (
<Button
variant="secondary"
onClick={start}
onClick={beginRecording}
disabled={disabled || submitLocked}
aria-label="음성으로 답변"
title="음성으로 답변"
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/features/interview/ui/live/MicLevelMeter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react'

const BARS = 5

// 녹음 중 마이크 입력을 실시간 막대로 보여주는 레벨 미터.
// AnalyserNode 의 주파수 데이터를 BARS 개 밴드로 묶어 각 막대 높이에 반영한다.
// 프레임마다 리렌더하지 않도록 ref 로 DOM transform 을 직접 갱신한다.
export function MicLevelMeter({ stream }: { stream: MediaStream | null }) {
const barRefs = useRef<(HTMLSpanElement | null)[]>([])

useEffect(() => {
if (!stream || typeof AudioContext === 'undefined') return
const ctx = new AudioContext()
void ctx.resume()
const source = ctx.createMediaStreamSource(stream)
const analyser = ctx.createAnalyser()
analyser.fftSize = 64
analyser.smoothingTimeConstant = 0.7
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
const band = Math.max(1, Math.floor(data.length / BARS))

let raf = 0
const tick = () => {
analyser.getByteFrequencyData(data)
for (let i = 0; i < BARS; i++) {
let sum = 0
for (let j = 0; j < band; j++) sum += data[i * band + j]
const level = sum / band / 255 // 0..1
const el = barRefs.current[i]
if (el) el.style.transform = `scaleY(${Math.max(0.18, Math.min(1, level * 1.8))})`
}
raf = requestAnimationFrame(tick)
}
tick()

return () => {
cancelAnimationFrame(raf)
source.disconnect()
void ctx.close()
}
}, [stream])

return (
<span className="flex h-6 items-center gap-[3px]" aria-hidden>
{Array.from({ length: BARS }).map((_, i) => (
<span
key={i}
ref={(el) => {
barRefs.current[i] = el
}}
className="h-full w-1 origin-center rounded-full bg-danger/70 transition-transform duration-75"
style={{ transform: 'scaleY(0.18)' }}
/>
))}
</span>
)
}