feat(frontend): voice capture hook, API client and types

MediaRecorder handling and the API call live in lib/, not in ui/, so
TreatmentDetailsEditor can stay presentational and take only a `voice` prop.

Container choice is made at record time and needs no transcode: Chrome and
Android give webm/opus, Safari and iPad give mp4/aac, and the transcription
endpoint accepts both. Safari's `audio/mp4` is sent as `m4a`, the name the
vendor's container list actually uses, so iPad recordings do not fail while
Chrome works. Older Safari shipped MediaRecorder without isTypeSupported, so
that path lets the browser choose rather than refusing outright.

From review of this commit:

- The auto-stop at maxMs guaranteed a 413. The client measures the final length
  after the recorder has stopped, so a recording that runs to the cap always
  reports slightly over it, and the server rejected exactly the recording the
  auto-stop existed to save. The server now allows a documented 2s tolerance and
  the client keeps reporting the true length, so telemetry stays honest.
- getUserMedia is async, so a permission granted after unmount installed a live
  stream the cleanup effect had already run past — leaving the browser's
  recording indicator lit with nothing listening. Guarded with a mounted ref.
- Client-side failures are now ApiError-shaped ({code, statusCode}) rather than
  bare Errors, because getUserFacingError only resolves that shape; without it
  errors.VOICE_MIC_DENIED was dead in all three locales.

Cancelling aborts the request, which closes the connection and aborts the
metered vendor call server-side rather than letting it settle unseen. The level
meter is best-effort: a blocked AudioContext costs the meter, not the recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:43:04 +03:30
parent 01b4ed7633
commit ff2bd09669
5 changed files with 416 additions and 4 deletions

View File

@@ -138,7 +138,7 @@ export class VoiceService {
this.logTelemetry({
locale: catalogLocale,
durationMs: dto.durationMs ?? null,
durationMs: dto.durationMs,
elapsedMs: Date.now() - startedAt,
asrCost,
llmCost,
@@ -196,9 +196,20 @@ export class VoiceService {
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;
if (max != null && durationMs != null && durationMs > max) {
if (max != null && durationMs > max + VoiceService.CAP_TOLERANCE_MS) {
throw new AppException(
ErrorCode.VOICE_CLIP_TOO_LONG,
HttpStatus.PAYLOAD_TOO_LARGE,
@@ -300,7 +311,7 @@ export class VoiceService {
*/
private logTelemetry(input: {
locale: string;
durationMs: number | null;
durationMs: number;
elapsedMs: number;
asrCost: number | null;
llmCost: number | null;