254 lines
8.1 KiB
TypeScript
254 lines
8.1 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
|
import { voiceApi } from '@/lib/api/voice';
|
||
|
|
import type { ApiError } from '@/types/api';
|
||
|
|
import type { VoiceExtractionResult, VoicePhase } from '@/types/voice';
|
||
|
|
import {
|
||
|
|
blobToBase64,
|
||
|
|
isMediaRecorderSupported,
|
||
|
|
mimeTypeToFormat,
|
||
|
|
pickRecordingMimeType,
|
||
|
|
} from './audioFormat';
|
||
|
|
|
||
|
|
export interface UseVoiceCaptureOptions {
|
||
|
|
/** null means uncapped; otherwise the recorder auto-stops here. */
|
||
|
|
maxMs: number | null;
|
||
|
|
onExtracted: (result: VoiceExtractionResult) => void;
|
||
|
|
onError: (error: unknown) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface VoiceCaptureState {
|
||
|
|
phase: VoicePhase;
|
||
|
|
elapsedMs: number;
|
||
|
|
/** 0..1, for the level meter — proves the microphone is actually hearing something. */
|
||
|
|
level: number;
|
||
|
|
maxMs: number | null;
|
||
|
|
onStart: () => void;
|
||
|
|
onStop: () => void;
|
||
|
|
onCancel: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const LEVEL_POLL_MS = 100;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Client-side failures must be ApiError-shaped or getUserFacingError cannot resolve them
|
||
|
|
* and every one renders the generic fallback, leaving errors.VOICE_MIC_DENIED dead.
|
||
|
|
*/
|
||
|
|
function clientError(code: string): ApiError {
|
||
|
|
return { statusCode: 0, code };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Microphone capture for treatment voice entry.
|
||
|
|
*
|
||
|
|
* Lives in lib/ rather than in the editor: TreatmentDetailsEditor stays presentational
|
||
|
|
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
|
||
|
|
*/
|
||
|
|
export function useVoiceCapture({
|
||
|
|
maxMs,
|
||
|
|
onExtracted,
|
||
|
|
onError,
|
||
|
|
}: UseVoiceCaptureOptions): VoiceCaptureState {
|
||
|
|
const [phase, setPhase] = useState<VoicePhase>('idle');
|
||
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||
|
|
const [level, setLevel] = useState(0);
|
||
|
|
|
||
|
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||
|
|
const streamRef = useRef<MediaStream | null>(null);
|
||
|
|
const chunksRef = useRef<Blob[]>([]);
|
||
|
|
const startedAtRef = useRef(0);
|
||
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||
|
|
const abortRef = useRef<AbortController | null>(null);
|
||
|
|
/** Set when the user cancels, so the recorder's stop handler discards instead of sending. */
|
||
|
|
const cancelledRef = useRef(false);
|
||
|
|
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
||
|
|
const mountedRef = useRef(true);
|
||
|
|
|
||
|
|
const teardown = useCallback(() => {
|
||
|
|
if (timerRef.current) {
|
||
|
|
clearInterval(timerRef.current);
|
||
|
|
timerRef.current = null;
|
||
|
|
}
|
||
|
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||
|
|
streamRef.current = null;
|
||
|
|
void audioContextRef.current?.close().catch(() => undefined);
|
||
|
|
audioContextRef.current = null;
|
||
|
|
recorderRef.current = null;
|
||
|
|
setLevel(0);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Releasing the microphone on unmount matters: the browser shows a recording indicator
|
||
|
|
// for as long as the track is live, and an orphaned one looks like the app is listening.
|
||
|
|
useEffect(() => () => {
|
||
|
|
mountedRef.current = false;
|
||
|
|
cancelledRef.current = true;
|
||
|
|
abortRef.current?.abort();
|
||
|
|
try {
|
||
|
|
recorderRef.current?.stop();
|
||
|
|
} catch {
|
||
|
|
// already stopped
|
||
|
|
}
|
||
|
|
teardown();
|
||
|
|
}, [teardown]);
|
||
|
|
|
||
|
|
const send = useCallback(
|
||
|
|
async (blob: Blob, mimeType: string, durationMs: number) => {
|
||
|
|
setPhase('processing');
|
||
|
|
const controller = new AbortController();
|
||
|
|
abortRef.current = controller;
|
||
|
|
try {
|
||
|
|
const audio = await blobToBase64(blob);
|
||
|
|
const response = await voiceApi.extract(
|
||
|
|
{
|
||
|
|
audio,
|
||
|
|
format: mimeTypeToFormat(mimeType),
|
||
|
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
|
|
durationMs,
|
||
|
|
},
|
||
|
|
controller.signal,
|
||
|
|
);
|
||
|
|
if (cancelledRef.current) return;
|
||
|
|
onExtracted(response.data);
|
||
|
|
} catch (error) {
|
||
|
|
if (cancelledRef.current || controller.signal.aborted) return;
|
||
|
|
onError(error);
|
||
|
|
} finally {
|
||
|
|
abortRef.current = null;
|
||
|
|
setPhase('idle');
|
||
|
|
setElapsedMs(0);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[onExtracted, onError],
|
||
|
|
);
|
||
|
|
|
||
|
|
const stop = useCallback(() => {
|
||
|
|
try {
|
||
|
|
recorderRef.current?.stop();
|
||
|
|
} catch {
|
||
|
|
teardown();
|
||
|
|
setPhase('idle');
|
||
|
|
}
|
||
|
|
}, [teardown]);
|
||
|
|
|
||
|
|
const onStart = useCallback(() => {
|
||
|
|
if (phase !== 'idle') return;
|
||
|
|
if (!isMediaRecorderSupported()) {
|
||
|
|
onError(clientError('VOICE_MIC_DENIED'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
cancelledRef.current = false;
|
||
|
|
void (async () => {
|
||
|
|
let stream: MediaStream;
|
||
|
|
try {
|
||
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
|
} catch {
|
||
|
|
// Permission refused, or no input device. Never a server round-trip.
|
||
|
|
onError(clientError('VOICE_MIC_DENIED'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!mountedRef.current) {
|
||
|
|
// Permission resolved after the component went away — release it immediately
|
||
|
|
// rather than leaving the browser's recording indicator lit.
|
||
|
|
stream.getTracks().forEach((track) => track.stop());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const mimeType = pickRecordingMimeType();
|
||
|
|
if (mimeType === null) {
|
||
|
|
stream.getTracks().forEach((track) => track.stop());
|
||
|
|
onError(clientError('VOICE_MIC_DENIED'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
streamRef.current = stream;
|
||
|
|
chunksRef.current = [];
|
||
|
|
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||
|
|
recorderRef.current = recorder;
|
||
|
|
|
||
|
|
recorder.ondataavailable = (event) => {
|
||
|
|
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||
|
|
};
|
||
|
|
recorder.onstop = () => {
|
||
|
|
const durationMs = Date.now() - startedAtRef.current;
|
||
|
|
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
|
||
|
|
teardown();
|
||
|
|
if (cancelledRef.current || blob.size === 0) {
|
||
|
|
setPhase('idle');
|
||
|
|
setElapsedMs(0);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs);
|
||
|
|
};
|
||
|
|
|
||
|
|
attachLevelMeter(stream, audioContextRef, setLevel);
|
||
|
|
|
||
|
|
startedAtRef.current = Date.now();
|
||
|
|
recorder.start();
|
||
|
|
setPhase('recording');
|
||
|
|
setElapsedMs(0);
|
||
|
|
|
||
|
|
timerRef.current = setInterval(() => {
|
||
|
|
const elapsed = Date.now() - startedAtRef.current;
|
||
|
|
setElapsedMs(elapsed);
|
||
|
|
// Auto-stop proceeds to processing with what was captured; discarding two minutes
|
||
|
|
// of dictation because a timer expired would be the worst possible failure.
|
||
|
|
if (maxMs != null && elapsed >= maxMs) stop();
|
||
|
|
}, LEVEL_POLL_MS);
|
||
|
|
})();
|
||
|
|
}, [maxMs, onError, phase, send, stop, teardown]);
|
||
|
|
|
||
|
|
const onCancel = useCallback(() => {
|
||
|
|
cancelledRef.current = true;
|
||
|
|
// Aborting closes the connection, which aborts the vendor call server-side. It is
|
||
|
|
// metered per minute, so letting it settle costs money for a result nobody sees.
|
||
|
|
abortRef.current?.abort();
|
||
|
|
try {
|
||
|
|
recorderRef.current?.stop();
|
||
|
|
} catch {
|
||
|
|
// already stopped
|
||
|
|
}
|
||
|
|
teardown();
|
||
|
|
setPhase('idle');
|
||
|
|
setElapsedMs(0);
|
||
|
|
}, [teardown]);
|
||
|
|
|
||
|
|
return { phase, elapsedMs, level, maxMs, onStart, onStop: stop, onCancel };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Drives the level meter from the live stream; failure here must not stop recording. */
|
||
|
|
function attachLevelMeter(
|
||
|
|
stream: MediaStream,
|
||
|
|
contextRef: React.MutableRefObject<AudioContext | null>,
|
||
|
|
setLevel: (value: number) => void,
|
||
|
|
) {
|
||
|
|
try {
|
||
|
|
const AudioContextCtor =
|
||
|
|
window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||
|
|
if (!AudioContextCtor) return;
|
||
|
|
|
||
|
|
const context = new AudioContextCtor();
|
||
|
|
contextRef.current = context;
|
||
|
|
const source = context.createMediaStreamSource(stream);
|
||
|
|
const analyser = context.createAnalyser();
|
||
|
|
analyser.fftSize = 512;
|
||
|
|
source.connect(analyser);
|
||
|
|
|
||
|
|
const data = new Uint8Array(analyser.frequencyBinCount);
|
||
|
|
const tick = () => {
|
||
|
|
if (contextRef.current !== context || context.state === 'closed') return;
|
||
|
|
analyser.getByteTimeDomainData(data);
|
||
|
|
let peak = 0;
|
||
|
|
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
|
||
|
|
setLevel(Math.min(1, peak / 128));
|
||
|
|
requestAnimationFrame(tick);
|
||
|
|
};
|
||
|
|
requestAnimationFrame(tick);
|
||
|
|
} catch {
|
||
|
|
// A missing or blocked AudioContext costs the meter, not the recording.
|
||
|
|
}
|
||
|
|
}
|