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>
2026-08-20 18:43:04 +03:30
|
|
|
/** 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;
|
|
|
|
|
|
|
|
|
|
/**
|
docs: cut the comments that were not earning their place
I wrote 731 comment lines on this branch against 4,530 lines of code — 14%,
where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads
like its surroundings, and this did not.
Removed by genre rather than by taste:
- restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the
map that literally shows it, and a docblock on startOfWeek explaining that
it returns the start of the week;
- narrating history — "this used to rebuild the whole map", "left the bar
recording forever" — which the commit message and git blame already carry;
- saying the same thing in several places: the "cannot record is not a
denied microphone" reason appeared three times in one file, and the
"aborting stops a per-minute metered call" reason across three files. Each
now lives once, where the behaviour it explains lives;
- defending decisions nobody would question, like why toLatinDigits is its
own module;
- over-explaining defensive branches, three separate comments to distinguish
null from missing-kind from unrecognised-kind.
What stays is what the code cannot say: the patient-right convention in
toFdi, whose failure mode is a valid code for the wrong tooth; the
"this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari
accepting no mimeType hint; and the invariants whose violation already cost
a bug — the body parser's middleware ordering and the dispatch panel's
auto-fill rules.
Comments only. The diff contains no non-comment line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:46:56 +08:00
|
|
|
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
|
|
|
|
|
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
|
|
|
|
|
* transcode step and the list is an intersection, not a preference.
|
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>
2026-08-20 18:43:04 +03:30
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|