@independo/capacitor-voice-recorder
Version:
Capacitor plugin for voice recording
635 lines (621 loc) • 27.2 kB
JavaScript
'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