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
16 changes: 14 additions & 2 deletions frontend/src/features/interview/lib/media/useVoiceRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export function useVoiceRecorder() {
: 'idle',
)

// 권한 요청은 사용자가 프롬프트를 방치하면 영원히 pending 이다. 취소 시 이 값을 올려
// 뒤늦게 도착한 스트림을 무효화한다 — 안 그러면 취소 후에도 녹음이 시작되고 마이크가 켜진다.
const requestIdRef = useRef(0)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
Expand All @@ -37,12 +40,19 @@ export function useVoiceRecorder() {

const start = useCallback(async (): Promise<boolean> => {
if (status === 'unsupported' || status === 'recording') return false
const requestId = ++requestIdRef.current
setStatus('requesting')
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch {
setStatus('denied')
// 취소 뒤 늦게 거부가 오면 이미 idle 인 상태를 denied 로 되돌리면 안 된다.
if (requestIdRef.current === requestId) setStatus('denied')
return false
}
// 대기 중에 취소했으면 방금 열린 트랙을 바로 닫는다(마이크 표시등이 계속 켜진 채 남는다).
if (requestIdRef.current !== requestId) {
stream.getTracks().forEach((t) => t.stop())
return false
}
const mimeType = pickMimeType()
Expand Down Expand Up @@ -79,8 +89,10 @@ export function useVoiceRecorder() {
})
}, [cleanup])

// 녹음 폐기(업로드 안 함).
// 녹음 폐기(업로드 안 함). 아직 권한 대기 중이어도 호출할 수 있다 — 그 경우 진행 중인
// getUserMedia 를 무효화해 사용자가 텍스트 입력으로 돌아갈 수 있게 한다.
const cancel = useCallback(() => {
requestIdRef.current += 1
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = null
Expand Down
19 changes: 14 additions & 5 deletions frontend/src/features/interview/ui/live/AnswerComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ function MicIcon() {

export function AnswerComposer({
disabled = false,
disabledReason = 'awaiting-question',
submitLocked = false,
onSubmit,
restoreDraft,
onSubmitVoice,
voiceUploading = false,
}: {
disabled?: boolean
/** 입력이 막힌 이유 — 안내 문구가 달라진다. 연결 끊김을 '질문 대기'로 적으면 오도한다. */
disabledReason?: 'awaiting-question' | 'disconnected'
submitLocked?: boolean
onSubmit: (content: string) => void
restoreDraft?: { content: string; nonce: number } | null
Expand Down Expand Up @@ -110,7 +113,9 @@ export function AnswerComposer({
{voiceUploading ? (
<span className="text-body text-fg">음성 답변 업로드 중…</span>
) : requesting ? (
<span className="text-body text-fg-muted">마이크 준비 중…</span>
<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">
Expand All @@ -125,12 +130,14 @@ export function AnswerComposer({
</span>
</span>
)}
{!voiceUploading && !requesting && (
{/* 권한 대기 중에도 나갈 수 있어야 한다 — 프롬프트를 놓치면 getUserMedia 가 영원히
응답하지 않고, 그동안 텍스트 입력창이 사라져 답변 자체가 막힌다. */}
{!voiceUploading && (
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" onClick={cancel}>
취소
{requesting ? '텍스트로 답변' : '취소'}
</Button>
<Button onClick={finishRecording}>전송</Button>
{!requesting && <Button onClick={finishRecording}>전송</Button>}
</div>
)}
</div>
Expand All @@ -150,7 +157,9 @@ export function AnswerComposer({
aria-label="답변 입력"
placeholder={
disabled
? '질문을 기다리는 중…'
? disabledReason === 'disconnected'
? '연결이 끊겨 지금은 보낼 수 없어요. 재연결되면 이어서 답변할 수 있습니다.'
: '질문을 기다리는 중…'
: submitLocked
? '질문이 끝나면 전송할 수 있어요'
: '답변을 입력하세요 (Enter 전송, Shift+Enter 줄바꿈)'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AnswerComposer } from './AnswerComposer'

// 녹음 훅은 실제 마이크를 잡으므로 상태만 흉내낸다.
const recorder = {
status: 'idle' as string,
stream: null,
start: vi.fn(async () => true),
stop: vi.fn(async () => null),
cancel: vi.fn(),
}
vi.mock('../../lib/media/useVoiceRecorder', () => ({
useVoiceRecorder: () => recorder,
}))
vi.mock('./MicLevelMeter', () => ({ MicLevelMeter: () => <div /> }))

beforeEach(() => {
recorder.status = 'idle'
recorder.cancel.mockClear()
recorder.start.mockClear()
})

function renderComposer(props: Partial<Parameters<typeof AnswerComposer>[0]> = {}) {
return render(
<AnswerComposer onSubmit={vi.fn()} onSubmitVoice={vi.fn()} {...props} />,
)
}

describe('AnswerComposer 음성 답변', () => {
// 권한 프롬프트를 놓치면 getUserMedia 가 영원히 pending 이다. 그동안 텍스트 입력창이
// 사라지므로, 빠져나갈 길이 없으면 답변 자체가 막힌다.
it('마이크 권한 대기 중에도 텍스트로 돌아갈 수 있다', async () => {
recorder.status = 'requesting'
renderComposer()

const escape = screen.getByRole('button', { name: '텍스트로 답변' })
await userEvent.click(escape)

expect(recorder.cancel).toHaveBeenCalled()
})

it('권한 대기 중에는 전송 버튼을 노출하지 않는다', () => {
recorder.status = 'requesting'
renderComposer()

expect(screen.queryByRole('button', { name: '전송' })).not.toBeInTheDocument()
})

it('녹음 중에는 취소와 전송을 모두 제공한다', () => {
recorder.status = 'recording'
renderComposer()

expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '전송' })).toBeInTheDocument()
})

// 연결이 끊겨 막힌 것을 '질문을 기다리는 중' 으로 적으면 AI 가 느린 줄 안다.
it('연결 끊김과 질문 대기를 다른 문구로 안내한다', () => {
const { unmount } = renderComposer({ disabled: true, disabledReason: 'disconnected' })
expect(screen.getByLabelText('답변 입력')).toHaveAttribute(
'placeholder',
expect.stringContaining('연결이 끊겨'),
)
unmount()

renderComposer({ disabled: true, disabledReason: 'awaiting-question' })
expect(screen.getByLabelText('답변 입력')).toHaveAttribute(
'placeholder',
'질문을 기다리는 중…',
)
})

it('마이크 권한이 거부되면 텍스트 전용 안내를 보여준다', () => {
recorder.status = 'denied'
renderComposer()

expect(screen.getByText(/마이크 권한이 거부되어/)).toBeInTheDocument()
})
})
3 changes: 3 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export function InterviewStage({
<div className="relative z-10">
<AnswerComposer
disabled={awaitingQuestion || connection !== 'open'}
disabledReason={
connection !== 'open' && !awaitingQuestion ? 'disconnected' : 'awaiting-question'
}
submitLocked={questionStreaming}
onSubmit={onSubmit}
restoreDraft={restoreDraft}
Expand Down
Loading