UNPKG

@independo/capacitor-voice-recorder

Version:
635 lines (621 loc) 27.2 kB
'use strict'; var core = require('@capacitor/core'); var filesystem = require('@capacitor/filesystem'); var write_blob = require('capacitor-blob-writer'); const VoiceRecorder = core.registerPlugin('VoiceRecorder', { web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.VoiceRecorderWeb()), }); /** * @param {Blob | string} blob * @returns {Promise<number>} Blob duration in seconds. */ async function getBlobDuration(blob) { // Check for AudioContext or webkitAudioContext (Safari) const AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) { throw new Error('AudioContext is not supported in this environment.'); } let audioContext = null; try { audioContext = new AudioCtx(); let arrayBuffer; if (typeof blob === 'string') { arrayBuffer = base64ToArrayBuffer(blob); } else { arrayBuffer = await blob.arrayBuffer(); } const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); return audioBuffer.duration; } catch (err) { throw new Error('Failed to get audio duration (AudioContext may require user interaction or is not supported): ' + (err instanceof Error ? err.message : String(err))); } finally { if (audioContext) { await audioContext.close(); } } } /** * Convert base64 string to ArrayBuffer. * @param base64 The base64 string to convert. * @returns The converted ArrayBuffer. * @remarks This function is exported for test coverage purposes. */ function base64ToArrayBuffer(base64) { const cleanBase64 = base64.replace(/^data:[^;]+;base64,/, ''); const binaryString = atob(cleanBase64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } /** Success wrapper for boolean plugin responses. */ const successResponse = () => ({ value: true }); /** Failure wrapper for boolean plugin responses. */ const failureResponse = () => ({ value: false }); /** Error for missing microphone permission. */ const missingPermissionError = () => new Error('MISSING_PERMISSION'); /** Error for attempting to start while already recording. */ const alreadyRecordingError = () => new Error('ALREADY_RECORDING'); /** Error for devices that cannot record audio. */ const deviceCannotVoiceRecordError = () => new Error('DEVICE_CANNOT_VOICE_RECORD'); /** Error for recorder start failures. */ const failedToRecordError = () => new Error('FAILED_TO_RECORD'); /** Error for empty or zero-length recordings. */ const emptyRecordingError = () => new Error('EMPTY_RECORDING'); /** Error for stopping without an active recording. */ const recordingHasNotStartedError = () => new Error('RECORDING_HAS_NOT_STARTED'); /** Error for failures when fetching recording data. */ const failedToFetchRecordingError = () => new Error('FAILED_TO_FETCH_RECORDING'); /** Error for browsers that do not support permission queries. */ const couldNotQueryPermissionStatusError = () => new Error('COULD_NOT_QUERY_PERMISSION_STATUS'); /** * 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. */ 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.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; /** Web adapter that delegates to the browser-specific implementation. */ class VoiceRecorderWebAdapter { constructor() { /** Browser implementation that talks to MediaRecorder APIs. */ this.voiceRecorderImpl = new VoiceRecorderImpl(); } /** Checks whether the browser can record audio. */ canDeviceVoiceRecord() { return VoiceRecorderImpl.canDeviceVoiceRecord(); } /** Returns whether the browser has microphone permission. */ hasAudioRecordingPermission() { return VoiceRecorderImpl.hasAudioRecordingPermission(); } /** Requests microphone permission through the browser. */ requestAudioRecordingPermission() { return VoiceRecorderImpl.requestAudioRecordingPermission(); } /** Starts a recording session using MediaRecorder. */ startRecording(options) { return this.voiceRecorderImpl.startRecording(options); } /** Stops the recording session and returns the payload. */ stopRecording() { return this.voiceRecorderImpl.stopRecording(); } /** Pauses the recording session when supported. */ pauseRecording() { return this.voiceRecorderImpl.pauseRecording(); } /** Resumes a paused recording session when supported. */ resumeRecording() { return this.voiceRecorderImpl.resumeRecording(); } /** Returns the current recording state. */ getCurrentStatus() { return this.voiceRecorderImpl.getCurrentStatus(); } } /** Default response shape when no config is provided. */ const DEFAULT_RESPONSE_FORMAT = 'legacy'; /** Parses a user-provided response format into a supported value. */ const resolveResponseFormat = (value) => { if (typeof value === 'string' && value.toLowerCase() === 'normalized') { return 'normalized'; } return DEFAULT_RESPONSE_FORMAT; }; /** Reads the response format from a Capacitor plugin config object. */ const getResponseFormatFromConfig = (config) => { if (config && typeof config === 'object' && 'responseFormat' in config) { return resolveResponseFormat(config.responseFormat); } return DEFAULT_RESPONSE_FORMAT; }; /** Maps legacy error messages to canonical error codes. */ const legacyToCanonical = { CANNOT_RECORD_ON_THIS_PHONE: 'DEVICE_CANNOT_VOICE_RECORD', }; /** Normalizes legacy error messages into canonical error codes. */ const toCanonicalErrorCode = (legacyMessage) => { var _a; return (_a = legacyToCanonical[legacyMessage]) !== null && _a !== void 0 ? _a : legacyMessage; }; /** Adds a canonical `code` field to Error-like objects when possible. */ const attachCanonicalErrorCode = (error) => { if (!error || typeof error !== 'object') { return; } const messageValue = error.message; if (typeof messageValue !== 'string') { return; } error.code = toCanonicalErrorCode(messageValue); }; /** Normalizes recording payloads into a stable contract shape. */ const normalizeRecordingData = (data) => { const { recordDataBase64, uri, msDuration, mimeType, fileExtension } = data.value; const normalizedValue = { msDuration, mimeType, fileExtension }; const trimmedUri = typeof uri === 'string' && uri.length > 0 ? uri : undefined; const trimmedBase64 = typeof recordDataBase64 === 'string' && recordDataBase64.length > 0 ? recordDataBase64 : undefined; if (trimmedUri) { normalizedValue.uri = trimmedUri; } else if (trimmedBase64) { normalizedValue.recordDataBase64 = trimmedBase64; } return { value: normalizedValue }; }; /** Orchestrates platform calls and normalizes responses when requested. */ class VoiceRecorderService { constructor(platform, responseFormat) { this.platform = platform; this.responseFormat = responseFormat; } /** Checks whether the device can record audio. */ canDeviceVoiceRecord() { return this.execute(() => this.platform.canDeviceVoiceRecord()); } /** Returns whether microphone permission is currently granted. */ hasAudioRecordingPermission() { return this.execute(() => this.platform.hasAudioRecordingPermission()); } /** Requests microphone permission from the user. */ requestAudioRecordingPermission() { return this.execute(() => this.platform.requestAudioRecordingPermission()); } /** Starts a recording session. */ startRecording(options) { return this.execute(() => this.platform.startRecording(options)); } /** Stops the recording session and formats the payload if needed. */ async stopRecording() { return this.execute(async () => { const data = await this.platform.stopRecording(); if (this.responseFormat === 'normalized') { return normalizeRecordingData(data); } return data; }); } /** Pauses the recording session when supported. */ pauseRecording() { return this.execute(() => this.platform.pauseRecording()); } /** Resumes a paused recording session when supported. */ resumeRecording() { return this.execute(() => this.platform.resumeRecording()); } /** Returns the current recording state. */ getCurrentStatus() { return this.execute(() => this.platform.getCurrentStatus()); } /** Wraps calls to apply canonical error codes when requested. */ async execute(fn) { try { return await fn(); } catch (error) { if (this.responseFormat === 'normalized') { attachCanonicalErrorCode(error); } throw error; } } } /** Web implementation of the VoiceRecorder Capacitor plugin. */ class VoiceRecorderWeb extends core.WebPlugin { constructor() { var _a, _b; super(); const pluginConfig = (_b = (_a = core.Capacitor === null || core.Capacitor === void 0 ? void 0 : core.Capacitor.config) === null || _a === void 0 ? void 0 : _a.plugins) === null || _b === void 0 ? void 0 : _b.VoiceRecorder; const responseFormat = getResponseFormatFromConfig(pluginConfig); this.service = new VoiceRecorderService(new VoiceRecorderWebAdapter(), responseFormat); } /** Checks whether the browser can record audio. */ canDeviceVoiceRecord() { return this.service.canDeviceVoiceRecord(); } /** Returns whether microphone permission is currently granted. */ hasAudioRecordingPermission() { return this.service.hasAudioRecordingPermission(); } /** Requests microphone permission from the user. */ requestAudioRecordingPermission() { return this.service.requestAudioRecordingPermission(); } /** Starts a recording session. */ startRecording(options) { return this.service.startRecording(options); } /** Stops the current recording session and returns the payload. */ stopRecording() { return this.service.stopRecording(); } /** Pauses the recording session when supported. */ pauseRecording() { return this.service.pauseRecording(); } /** Resumes a paused recording session when supported. */ resumeRecording() { return this.service.resumeRecording(); } /** Returns the current recording state. */ getCurrentStatus() { return this.service.getCurrentStatus(); } } var web = /*#__PURE__*/Object.freeze({ __proto__: null, VoiceRecorderWeb: VoiceRecorderWeb }); exports.VoiceRecorder = VoiceRecorder; //# sourceMappingURL=plugin.cjs.js.map