UNPKG

@independo/capacitor-voice-recorder

Version:
358 lines 16.9 kB
import { Filesystem } from '@capacitor/filesystem'; import write_blob from 'capacitor-blob-writer'; import getBlobDuration from './get-blob-duration'; import { alreadyRecordingError, couldNotQueryPermissionStatusError, deviceCannotVoiceRecordError, emptyRecordingError, failedToFetchRecordingError, failedToRecordError, failureResponse, missingPermissionError, recordingHasNotStartedError, successResponse, } from './predefined-web-responses'; /** * Ordered MIME types to probe for audio recording via `MediaRecorder.isTypeSupported()`. * * ⚠️ The order is intentional and MUST remain stable unless you also update the * selection policy in code and test on Safari/iOS + WebViews. * * ✅ What this list is used for * - Selecting a `mimeType` for `new MediaRecorder(stream, { mimeType })`. * * ❌ What this list does NOT guarantee * - It does NOT guarantee that the recorded output will be playable via the * HTML `<audio>` element in the same browser. * * Real-world caveat (important): * - We have observed cases where `MediaRecorder.isTypeSupported('audio/webm;codecs=opus')` * returned `true`, the recorder produced a Blob, but `<audio>` could not play it. * This can happen due to container/codec playback support differences, platform * quirks (especially Safari/iOS / WKWebView), or incomplete WebM playback support. * * Current selection behavior in this implementation: * - By default, MIME selection treats recorder support and playback support as separate * capabilities and probes both: * - Recorder capability: `MediaRecorder.isTypeSupported(type)` * - Playback capability: `audio.canPlayType(type)` * - This default can be disabled via `RecordingOptions.requirePlaybackSupport = false` * to fall back to recorder-only probing. * * Keeping legacy keys: * - Some entries are kept even if they overlap (e.g. `audio/mp4` and explicit codec), * to maximize compatibility across differing browser implementations. */ const POSSIBLE_MIME_TYPES = { // ✅ Most universal 'audio/mp4;codecs="mp4a.40.2"': '.m4a', // AAC in MP4 (explicit codec helps detection) 'audio/mp4': '.m4a', // (legacy key kept; broad support) 'audio/aac': '.aac', // (legacy key kept; less common in the wild) 'audio/mpeg': '.mp3', // MP3 (universal) 'audio/wav': '.wav', // WAV (universal, big files) // ✅ Modern high-quality (very widely supported, but slightly less “universal” than MP3/AAC) 'audio/webm;codecs="opus"': '.webm', // Opus in WebM (explicit codec helps detection) 'audio/webm;codecs=opus': '.webm', // (legacy key kept) 'audio/webm': '.webm', // (legacy key kept; container-only, codec-dependent) // ⚠️ Least universal (Safari/iOS historically the limiting factor) 'audio/ogg;codecs=opus': '.ogg', // (legacy key kept) 'audio/ogg;codecs=vorbis': '.ogg', // Ogg Vorbis (weakest mainstream support) }; /** Creates a promise that never resolves. */ const neverResolvingPromise = () => new Promise(() => undefined); /** Browser implementation backed by MediaRecorder and Capacitor Filesystem. */ export class VoiceRecorderImpl { constructor() { /** Active MediaRecorder instance, if recording. */ this.mediaRecorder = null; /** Collected data chunks from MediaRecorder. */ this.chunks = []; /** Promise resolved when the recorder stops and payload is ready. */ this.pendingResult = neverResolvingPromise(); } /** * Returns whether the browser can start a recording session. * * On web this checks: * - `navigator.mediaDevices.getUserMedia` * - at least one supported recording MIME type using {@link getSupportedMimeType} * * The optional `requirePlaybackSupport` flag is forwarded to MIME selection and defaults * to `true` when omitted. */ static async canDeviceVoiceRecord(options) { var _a; if (((_a = navigator === null || navigator === void 0 ? void 0 : navigator.mediaDevices) === null || _a === void 0 ? void 0 : _a.getUserMedia) == null || VoiceRecorderImpl.getSupportedMimeType({ requirePlaybackSupport: options === null || options === void 0 ? void 0 : options.requirePlaybackSupport, }) == null) { return failureResponse(); } else { return successResponse(); } } /** * Starts a recording session using `MediaRecorder`. * * The selected MIME type is resolved once at start time (using the optional * `requirePlaybackSupport` flag from `RecordingOptions`) and reused for the final Blob * and file extension to keep the recording payload internally consistent. */ async startRecording(options) { if (this.mediaRecorder != null) { throw alreadyRecordingError(); } const deviceCanRecord = await VoiceRecorderImpl.canDeviceVoiceRecord(options); if (!deviceCanRecord.value) { throw deviceCannotVoiceRecordError(); } const havingPermission = await VoiceRecorderImpl.hasAudioRecordingPermission().catch(() => successResponse()); if (!havingPermission.value) { throw missingPermissionError(); } return navigator.mediaDevices .getUserMedia({ audio: true }) .then((stream) => this.onSuccessfullyStartedRecording(stream, options)) .catch(this.onFailedToStartRecording.bind(this)); } /** Stops the current recording and resolves the pending payload. */ async stopRecording() { if (this.mediaRecorder == null) { throw recordingHasNotStartedError(); } try { this.mediaRecorder.stop(); this.mediaRecorder.stream.getTracks().forEach((track) => track.stop()); return this.pendingResult; } catch (ignore) { throw failedToFetchRecordingError(); } finally { this.prepareInstanceForNextOperation(); } } /** Returns whether the browser has microphone permission. */ static async hasAudioRecordingPermission() { // Safari does not support navigator.permissions.query if (!navigator.permissions.query) { if (navigator.mediaDevices !== undefined) { return navigator.mediaDevices .getUserMedia({ audio: true }) .then(() => successResponse()) .catch(() => { throw couldNotQueryPermissionStatusError(); }); } } return navigator.permissions .query({ name: 'microphone' }) .then((result) => ({ value: result.state === 'granted' })) .catch(() => { throw couldNotQueryPermissionStatusError(); }); } /** Requests microphone permission from the browser. */ static async requestAudioRecordingPermission() { const havingPermission = await VoiceRecorderImpl.hasAudioRecordingPermission().catch(() => failureResponse()); if (havingPermission.value) { return successResponse(); } return navigator.mediaDevices .getUserMedia({ audio: true }) .then(() => successResponse()) .catch(() => failureResponse()); } /** Pauses the recording session when supported. */ pauseRecording() { if (this.mediaRecorder == null) { throw recordingHasNotStartedError(); } else if (this.mediaRecorder.state === 'recording') { this.mediaRecorder.pause(); return Promise.resolve(successResponse()); } else { return Promise.resolve(failureResponse()); } } /** Resumes a paused recording session when supported. */ resumeRecording() { if (this.mediaRecorder == null) { throw recordingHasNotStartedError(); } else if (this.mediaRecorder.state === 'paused') { this.mediaRecorder.resume(); return Promise.resolve(successResponse()); } else { return Promise.resolve(failureResponse()); } } /** Returns the current recording status from MediaRecorder. */ getCurrentStatus() { if (this.mediaRecorder == null) { return Promise.resolve({ status: 'NONE' }); } else if (this.mediaRecorder.state === 'recording') { return Promise.resolve({ status: 'RECORDING' }); } else if (this.mediaRecorder.state === 'paused') { return Promise.resolve({ status: 'PAUSED' }); } else { return Promise.resolve({ status: 'NONE' }); } } /** * Returns the first MIME type (key of {@link POSSIBLE_MIME_TYPES}) that the current * environment reports as supported for recording via `MediaRecorder.isTypeSupported()`, * optionally requiring native HTML `<audio>` playback support too. * * The search order is the iteration order of {@link POSSIBLE_MIME_TYPES}. * * @typeParam T - A MIME type string that exists as a key in {@link POSSIBLE_MIME_TYPES}. * * @returns The first supported MIME type for `MediaRecorder`, or `null` if: * - `MediaRecorder` is unavailable, or * - no configured MIME types are supported. * * ⚠️ Important: `MediaRecorder` support ≠ `<audio>` playback support * * Some browsers/platforms can claim support for recording a format (notably WebM/Opus) * but still fail to play the resulting Blob through the native HTML audio pipeline. * This mismatch is especially likely on Safari/iOS / WKWebView variants, so the default * behavior also probes `HTMLAudioElement.canPlayType(type)` when available. * * Selection policy when playback probing is enabled: * - keep the global priority order from {@link POSSIBLE_MIME_TYPES} * - among recordable types, prefer the first `"probably"` playable candidate * - otherwise return the first `"maybe"` playable candidate * - treat `""` as not playable * * Note: The <audio> element is never attached to the DOM, so it won't appear to users or assistive tech. * * Fallback behavior: * - If `document` / `audio.canPlayType` is unavailable (e.g. SSR-like environments), * this falls back to record-only probing. */ static getSupportedMimeType(options) { var _a, _b, _c, _d, _e; if ((MediaRecorder === null || MediaRecorder === void 0 ? void 0 : MediaRecorder.isTypeSupported) == null) return null; const orderedTypes = Object.keys(POSSIBLE_MIME_TYPES); const recordSupportedTypes = orderedTypes.filter((type) => MediaRecorder.isTypeSupported(type)); if (recordSupportedTypes.length === 0) return null; const requirePlaybackSupport = (_a = options === null || options === void 0 ? void 0 : options.requirePlaybackSupport) !== null && _a !== void 0 ? _a : VoiceRecorderImpl.DEFAULT_REQUIRE_PLAYBACK_SUPPORT; if (!requirePlaybackSupport) { return (_b = recordSupportedTypes[0]) !== null && _b !== void 0 ? _b : null; } if (typeof document === 'undefined' || typeof document.createElement !== 'function') { return (_c = recordSupportedTypes[0]) !== null && _c !== void 0 ? _c : null; } const audioElement = document.createElement('audio'); if (typeof audioElement.canPlayType !== 'function') { return (_d = recordSupportedTypes[0]) !== null && _d !== void 0 ? _d : null; } let firstProbably = null; let firstMaybe = null; for (const type of recordSupportedTypes) { const playbackSupport = audioElement.canPlayType(type); if (playbackSupport === 'probably') { firstProbably = type; break; } if (playbackSupport === 'maybe' && firstMaybe == null) { firstMaybe = type; } } return (_e = firstProbably !== null && firstProbably !== void 0 ? firstProbably : firstMaybe) !== null && _e !== void 0 ? _e : null; } /** Initializes MediaRecorder and wires up handlers. */ onSuccessfullyStartedRecording(stream, options) { this.pendingResult = new Promise((resolve, reject) => { const mimeType = VoiceRecorderImpl.getSupportedMimeType({ requirePlaybackSupport: options === null || options === void 0 ? void 0 : options.requirePlaybackSupport, }); if (mimeType == null) { this.prepareInstanceForNextOperation(); reject(failedToRecordError()); return; } this.mediaRecorder = new MediaRecorder(stream, { mimeType }); this.mediaRecorder.onerror = () => { this.prepareInstanceForNextOperation(); reject(failedToRecordError()); }; this.mediaRecorder.onstop = async () => { var _a, _b, _c, _d, _e, _f; const mt = (_b = (_a = this.mediaRecorder) === null || _a === void 0 ? void 0 : _a.mimeType) !== null && _b !== void 0 ? _b : mimeType; const blobVoiceRecording = new Blob(this.chunks, { type: mt }); if (blobVoiceRecording.size <= 0) { this.prepareInstanceForNextOperation(); reject(emptyRecordingError()); return; } let uri = undefined; let recordDataBase64 = ''; const fileExtension = ((_c = POSSIBLE_MIME_TYPES[mimeType]) !== null && _c !== void 0 ? _c : '').replace(/^\./, ''); if (options === null || options === void 0 ? void 0 : options.directory) { const subDirectory = (_f = (_e = (_d = options.subDirectory) === null || _d === void 0 ? void 0 : _d.match(/^\/?(.+[^/])\/?$/)) === null || _e === void 0 ? void 0 : _e[1]) !== null && _f !== void 0 ? _f : ''; const path = `${subDirectory}/recording-${new Date().getTime()}${POSSIBLE_MIME_TYPES[mt]}`; await write_blob({ blob: blobVoiceRecording, directory: options.directory, fast_mode: true, path, recursive: true, }); ({ uri } = await Filesystem.getUri({ directory: options.directory, path })); } else { recordDataBase64 = await VoiceRecorderImpl.blobToBase64(blobVoiceRecording); } const recordingDuration = await getBlobDuration(blobVoiceRecording); this.prepareInstanceForNextOperation(); resolve({ value: { recordDataBase64, mimeType: mt, fileExtension, msDuration: recordingDuration * 1000, uri } }); }; this.mediaRecorder.ondataavailable = (event) => this.chunks.push(event.data); this.mediaRecorder.start(); }); return successResponse(); } /** Handles failures from getUserMedia. */ onFailedToStartRecording() { this.prepareInstanceForNextOperation(); throw failedToRecordError(); } /** Converts a Blob payload into a base64 string. */ static blobToBase64(blob) { return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => { const recordingResult = String(reader.result); const splitResult = recordingResult.split('base64,'); const toResolve = splitResult.length > 1 ? splitResult[1] : recordingResult; resolve(toResolve.trim()); }; reader.readAsDataURL(blob); }); } /** Resets state for the next recording attempt. */ prepareInstanceForNextOperation() { if (this.mediaRecorder != null && this.mediaRecorder.state === 'recording') { try { this.mediaRecorder.stop(); } catch (ignore) { console.warn('Failed to stop recording during cleanup'); } } this.pendingResult = neverResolvingPromise(); this.mediaRecorder = null; this.chunks = []; } } /** Default behavior for web MIME selection: require recorder + playback support. */ VoiceRecorderImpl.DEFAULT_REQUIRE_PLAYBACK_SUPPORT = true; //# sourceMappingURL=VoiceRecorderImpl.js.map