Files
dyolink/frontend/src/lib/voice/audioFormat.ts

63 lines
2.4 KiB
TypeScript
Raw Normal View History

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;
/**
* 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)
);
}