feat(treatment): add voice detail entry #65
@@ -138,7 +138,7 @@ export class VoiceService {
|
|||||||
|
|
||||||
this.logTelemetry({
|
this.logTelemetry({
|
||||||
locale: catalogLocale,
|
locale: catalogLocale,
|
||||||
durationMs: dto.durationMs ?? null,
|
durationMs: dto.durationMs,
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
asrCost,
|
asrCost,
|
||||||
llmCost,
|
llmCost,
|
||||||
@@ -196,9 +196,20 @@ export class VoiceService {
|
|||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertWithinCap(durationMs?: number) {
|
/**
|
||||||
|
* Grace above the configured cap.
|
||||||
|
*
|
||||||
|
* The client auto-stops when elapsed >= maxMs, then measures the final length after the
|
||||||
|
* recorder has actually stopped — so a recording that runs to the cap always reports
|
||||||
|
* slightly over it. Without this tolerance the auto-stop would guarantee a rejection,
|
||||||
|
* discarding exactly the recording it was meant to save. The client still reports the
|
||||||
|
* true length, so telemetry stays honest.
|
||||||
|
*/
|
||||||
|
private static readonly CAP_TOLERANCE_MS = 2_000;
|
||||||
|
|
||||||
|
private assertWithinCap(durationMs: number) {
|
||||||
const max = this.voiceConfig.maxRecordingMs;
|
const max = this.voiceConfig.maxRecordingMs;
|
||||||
if (max != null && durationMs != null && durationMs > max) {
|
if (max != null && durationMs > max + VoiceService.CAP_TOLERANCE_MS) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCode.VOICE_CLIP_TOO_LONG,
|
ErrorCode.VOICE_CLIP_TOO_LONG,
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
@@ -300,7 +311,7 @@ export class VoiceService {
|
|||||||
*/
|
*/
|
||||||
private logTelemetry(input: {
|
private logTelemetry(input: {
|
||||||
locale: string;
|
locale: string;
|
||||||
durationMs: number | null;
|
durationMs: number;
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
asrCost: number | null;
|
asrCost: number | null;
|
||||||
llmCost: number | null;
|
llmCost: number | null;
|
||||||
|
|||||||
28
frontend/src/lib/api/voice.ts
Normal file
28
frontend/src/lib/api/voice.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { VoiceAvailability, VoiceExtractionResult } from '@/types/voice';
|
||||||
|
|
||||||
|
export interface ExtractVoicePayload {
|
||||||
|
/** Base64 audio, no data: prefix. */
|
||||||
|
audio: string;
|
||||||
|
format: string;
|
||||||
|
/** IANA zone — the server derives "today" from it for relative deadlines. */
|
||||||
|
timeZone: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceApi = {
|
||||||
|
availability: async (): Promise<{ success: boolean; data: VoiceAvailability }> => {
|
||||||
|
const response = await apiClient.get('/voice/availability');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
extract: async (
|
||||||
|
payload: ExtractVoicePayload,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
|
||||||
|
// The signal is forwarded so cancelling closes the connection, which aborts the
|
||||||
|
// metered vendor call server-side rather than letting it settle unseen.
|
||||||
|
const response = await apiClient.post('/voice/extract', payload, { signal });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
62
frontend/src/lib/voice/audioFormat.ts
Normal file
62
frontend/src/lib/voice/audioFormat.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/** Containers the backend accepts, in the order we prefer to record them. */
|
||||||
|
const PREFERRED_MIME_TYPES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/ogg',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a container this browser can record AND the backend accepts.
|
||||||
|
*
|
||||||
|
* Chrome and Android produce webm/opus; Safari and iPad produce mp4/aac. Both go to the
|
||||||
|
* vendor unmodified, so there is no transcode step — but the choice still has to be made
|
||||||
|
* at record time, and `isTypeSupported` is missing entirely on older Safari.
|
||||||
|
*/
|
||||||
|
export function pickRecordingMimeType(): string | null {
|
||||||
|
if (typeof MediaRecorder === 'undefined') return null;
|
||||||
|
if (typeof MediaRecorder.isTypeSupported !== 'function') {
|
||||||
|
// Safari <14.1 shipped MediaRecorder without the feature check; let it choose.
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
for (const type of PREFERRED_MIME_TYPES) {
|
||||||
|
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */
|
||||||
|
export function mimeTypeToFormat(mimeType: string): string {
|
||||||
|
const base = mimeType.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||||
|
const subtype = base.startsWith('audio/') ? base.slice('audio/'.length) : base;
|
||||||
|
// Safari/iOS records `audio/mp4`, but the transcription endpoint's documented container
|
||||||
|
// list names m4a, not mp4. Same container; send the name the vendor documents, so iPad
|
||||||
|
// recordings do not fail while Chrome's webm works.
|
||||||
|
if (subtype === 'x-m4a' || subtype === 'm4a' || subtype === 'mp4') return 'm4a';
|
||||||
|
if (subtype === 'mpeg') return 'mp3';
|
||||||
|
return subtype || 'webm';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blob → base64 without the `data:` prefix, which the API does not want. */
|
||||||
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
// Chunked to avoid blowing the argument limit on a two-minute recording.
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMediaRecorderSupported(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof MediaRecorder !== 'undefined' &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
|
);
|
||||||
|
}
|
||||||
253
frontend/src/lib/voice/useVoiceCapture.ts
Normal file
253
frontend/src/lib/voice/useVoiceCapture.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
'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.
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/src/types/voice.ts
Normal file
58
frontend/src/types/voice.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment';
|
||||||
|
|
||||||
|
/** Mirrors the backend's ResolvedExtraction — values already resolved, plus what was not. */
|
||||||
|
|
||||||
|
export type VoiceUnresolvedReason =
|
||||||
|
| 'not_permanent_tooth'
|
||||||
|
| 'position_out_of_range'
|
||||||
|
| 'malformed'
|
||||||
|
| 'span_not_same_arch'
|
||||||
|
| 'unknown_catalog_code'
|
||||||
|
| 'tooth_not_selected'
|
||||||
|
| 'invalid_date';
|
||||||
|
|
||||||
|
export interface VoiceUnresolvedItem {
|
||||||
|
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
|
||||||
|
spoken: string;
|
||||||
|
reason: VoiceUnresolvedReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceProsthesisResult {
|
||||||
|
byTooth: Record<string, string>;
|
||||||
|
/** False means the case cannot ship — every tooth needs a prosthesis type. */
|
||||||
|
complete: boolean;
|
||||||
|
missingTeeth: FdiToothId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceExtractionResult {
|
||||||
|
transcript: string;
|
||||||
|
treatmentType: string | null;
|
||||||
|
teeth: FdiToothId[];
|
||||||
|
toothSelectionGroups: ToothSelectionGroup[];
|
||||||
|
comment: string | null;
|
||||||
|
prosthesis: VoiceProsthesisResult | null;
|
||||||
|
labId: string | null;
|
||||||
|
/** When false, the lab row must not tick itself — the name only approximately matched. */
|
||||||
|
labMatchExact: boolean;
|
||||||
|
dueDate: string | null;
|
||||||
|
unresolved: VoiceUnresolvedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceAvailability {
|
||||||
|
enabled: boolean;
|
||||||
|
locales: string[];
|
||||||
|
/** null means uncapped. */
|
||||||
|
maxRecordingMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which review rows the clinician ticked. */
|
||||||
|
export interface VoiceApplySelection {
|
||||||
|
treatmentType: boolean;
|
||||||
|
teeth: boolean;
|
||||||
|
comment: boolean;
|
||||||
|
prosthesis: boolean;
|
||||||
|
lab: boolean;
|
||||||
|
dueDate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoicePhase = 'idle' | 'recording' | 'processing';
|
||||||
Reference in New Issue
Block a user