cordova-plugin-audioinput
Version:
Audio input capture plugin for Cordova and Capacitor - real-time microphone access with streaming and file recording support
233 lines (198 loc) • 6.66 kB
text/typescript
import { WebPlugin } from '@capacitor/core';
import type {
AudioInputPlugin,
AudioInputOptions,
AudioDataEvent,
AudioErrorEvent,
AudioStateEvent,
} from './definitions';
/**
* Web implementation of AudioInput plugin
* Uses Web Audio API for browser-based audio capture
*/
export class AudioInputWeb extends WebPlugin implements AudioInputPlugin {
private audioContext: AudioContext | null = null;
private mediaStream: MediaStream | null = null;
private scriptProcessor: ScriptProcessorNode | null = null;
private micGainNode: GainNode | null = null;
private capturing = false;
private hasMicrophonePermission = false;
private options: AudioInputOptions = {};
async initialize(options: AudioInputOptions): Promise<void> {
this.options = { ...this.options, ...options };
this.emitStateChange('idle');
return Promise.resolve();
}
async checkMicrophonePermission(): Promise<{ granted: boolean }> {
if (this.mediaStream !== null || this.hasMicrophonePermission) {
return { granted: true };
}
if (navigator.permissions?.query) {
try {
const status = await navigator.permissions.query({
name: 'microphone' as PermissionName,
});
const granted = status.state === 'granted';
if (granted) {
this.hasMicrophonePermission = true;
}
return { granted };
} catch {
// Fall through to cached permission state.
}
}
return { granted: this.hasMicrophonePermission };
}
async getMicrophonePermission(): Promise<{ granted: boolean }> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// We got permission, but we'll close this stream for now
// It will be reopened in start()
stream.getTracks().forEach(track => track.stop());
this.hasMicrophonePermission = true;
return { granted: true };
} catch (error) {
console.error('Microphone permission denied:', error);
return { granted: false };
}
}
async start(options?: AudioInputOptions): Promise<void> {
if (this.capturing) {
throw new Error('Already capturing audio');
}
if (options) {
this.options = { ...this.options, ...options };
}
this.warnUnsupportedOptions();
try {
// Request microphone access
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
autoGainControl: false,
noiseSuppression: false,
},
});
this.hasMicrophonePermission = true;
// Create audio context
const AudioContext =
window.AudioContext || (window as any).webkitAudioContext;
this.audioContext = new AudioContext();
// Create nodes
const source = this.audioContext.createMediaStreamSource(
this.mediaStream,
);
this.micGainNode = this.audioContext.createGain();
// Create script processor for audio data
const bufferSize = this.options.bufferSize || 16384;
this.scriptProcessor = this.audioContext.createScriptProcessor(
bufferSize,
1,
1,
);
this.scriptProcessor.onaudioprocess = event => {
if (!this.capturing) return;
const inputData = event.inputBuffer.getChannelData(0);
const samples = this.processSamples(inputData);
const format = this.options.format || 'PCM_16BIT';
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 0;
this.notifyListeners('audioData', {
data: samples,
sampleRate,
channels: 1,
format,
timestamp: Date.now(),
} as AudioDataEvent);
};
// Connect the audio graph
source.connect(this.micGainNode);
this.micGainNode.connect(this.scriptProcessor);
this.scriptProcessor.connect(this.audioContext.destination);
this.capturing = true;
this.emitStateChange('capturing');
} catch (error: any) {
this.emitError(error.message || 'Failed to start audio capture');
this.emitStateChange(
'error',
error.message || 'Failed to start audio capture',
);
throw error;
}
}
async stop(): Promise<{ fileUrl?: string }> {
this.capturing = false;
if (this.scriptProcessor) {
this.scriptProcessor.disconnect();
this.scriptProcessor = null;
}
if (this.micGainNode) {
this.micGainNode.disconnect();
this.micGainNode = null;
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
this.mediaStream = null;
}
if (this.audioContext) {
await this.audioContext.close();
this.audioContext = null;
}
this.emitStateChange('stopped');
return {};
}
async isCapturing(): Promise<{ capturing: boolean }> {
return { capturing: this.capturing };
}
async getCfg(): Promise<AudioInputOptions> {
return { ...this.options };
}
/**
* Process audio samples according to options
*/
private processSamples(inputData: Float32Array): number[] {
const normalize = this.options.normalize !== false;
const normalizationFactor = this.options.normalizationFactor || 32767.0;
if (normalize) {
// Return as Float32Array (already normalized -1 to 1)
return Array.from(inputData);
} else {
// Convert to Int16Array
const output = new Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
const sample = Math.max(-1, Math.min(1, inputData[i]));
output[i] = Math.floor(sample * normalizationFactor);
}
return output;
}
}
private emitStateChange(
state: AudioStateEvent['state'],
message?: string,
): void {
this.notifyListeners('stateChange', {
state,
message,
timestamp: Date.now(),
} as AudioStateEvent);
}
private emitError(message: string, code?: string): void {
this.notifyListeners('audioError', {
message,
code,
} as AudioErrorEvent);
}
private warnUnsupportedOptions(): void {
if (this.options.fileUrl) {
const message =
'Web implementation does not support fileUrl recording; continuing in stream mode.';
console.warn(message);
this.emitError(message, 'WEB_FILE_RECORDING_UNSUPPORTED');
}
if ((this.options.channels || 1) !== 1) {
const message =
'Web implementation currently captures mono only; requested channels value is ignored.';
console.warn(message);
this.emitError(message, 'WEB_CHANNELS_UNSUPPORTED');
}
}
}