|
| 1 | +import { useCallback, useEffect, useRef, useState } from 'react' |
| 2 | + |
| 3 | +export type RecorderStatus = 'idle' | 'requesting' | 'recording' | 'denied' | 'unsupported' |
| 4 | + |
| 5 | +// MediaRecorder 가 만들 수 있는 후보. 백엔드 허용 목록(webm/ogg/mpeg/mp4/wav)과 교집합. |
| 6 | +const PREFERRED_MIME = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'] |
| 7 | + |
| 8 | +function pickMimeType(): string | undefined { |
| 9 | + if (typeof MediaRecorder === 'undefined') return undefined |
| 10 | + return PREFERRED_MIME.find((t) => MediaRecorder.isTypeSupported(t)) |
| 11 | +} |
| 12 | + |
| 13 | +// 라이브 면접 음성 답변 녹음. start → 마이크 권한 요청 + 녹음, stop → 오디오 Blob 반환. |
| 14 | +// 권한 거부/미지원 시 status 로 알려서 호출부가 텍스트 입력으로 fallback 한다. |
| 15 | +export function useVoiceRecorder() { |
| 16 | + const [status, setStatus] = useState<RecorderStatus>(() => |
| 17 | + typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia |
| 18 | + ? 'unsupported' |
| 19 | + : 'idle', |
| 20 | + ) |
| 21 | + |
| 22 | + const recorderRef = useRef<MediaRecorder | null>(null) |
| 23 | + const streamRef = useRef<MediaStream | null>(null) |
| 24 | + const chunksRef = useRef<Blob[]>([]) |
| 25 | + |
| 26 | + const cleanup = useCallback(() => { |
| 27 | + streamRef.current?.getTracks().forEach((t) => t.stop()) |
| 28 | + streamRef.current = null |
| 29 | + recorderRef.current = null |
| 30 | + chunksRef.current = [] |
| 31 | + }, []) |
| 32 | + |
| 33 | + useEffect(() => cleanup, [cleanup]) |
| 34 | + |
| 35 | + const start = useCallback(async (): Promise<boolean> => { |
| 36 | + if (status === 'unsupported' || status === 'recording') return false |
| 37 | + setStatus('requesting') |
| 38 | + let stream: MediaStream |
| 39 | + try { |
| 40 | + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
| 41 | + } catch { |
| 42 | + setStatus('denied') |
| 43 | + return false |
| 44 | + } |
| 45 | + const mimeType = pickMimeType() |
| 46 | + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) |
| 47 | + chunksRef.current = [] |
| 48 | + recorder.ondataavailable = (e) => { |
| 49 | + if (e.data.size > 0) chunksRef.current.push(e.data) |
| 50 | + } |
| 51 | + recorder.start() |
| 52 | + streamRef.current = stream |
| 53 | + recorderRef.current = recorder |
| 54 | + setStatus('recording') |
| 55 | + return true |
| 56 | + }, [status]) |
| 57 | + |
| 58 | + // 녹음 중지 후 Blob 반환. 녹음 중이 아니면 null. |
| 59 | + const stop = useCallback((): Promise<Blob | null> => { |
| 60 | + const recorder = recorderRef.current |
| 61 | + if (!recorder || recorder.state === 'inactive') { |
| 62 | + cleanup() |
| 63 | + setStatus('idle') |
| 64 | + return Promise.resolve(null) |
| 65 | + } |
| 66 | + return new Promise((resolve) => { |
| 67 | + recorder.onstop = () => { |
| 68 | + const type = recorder.mimeType || chunksRef.current[0]?.type || 'audio/webm' |
| 69 | + const blob = new Blob(chunksRef.current, { type }) |
| 70 | + cleanup() |
| 71 | + setStatus('idle') |
| 72 | + resolve(blob.size > 0 ? blob : null) |
| 73 | + } |
| 74 | + recorder.stop() |
| 75 | + }) |
| 76 | + }, [cleanup]) |
| 77 | + |
| 78 | + // 녹음 폐기(업로드 안 함). |
| 79 | + const cancel = useCallback(() => { |
| 80 | + const recorder = recorderRef.current |
| 81 | + if (recorder && recorder.state !== 'inactive') { |
| 82 | + recorder.onstop = null |
| 83 | + recorder.stop() |
| 84 | + } |
| 85 | + cleanup() |
| 86 | + setStatus('idle') |
| 87 | + }, [cleanup]) |
| 88 | + |
| 89 | + return { status, start, stop, cancel } |
| 90 | +} |
0 commit comments