UNPKG

@siteed/expo-audio-studio

Version:

Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web

360 lines (325 loc) 12.9 kB
// src/ExpoAudioStreamModule.web.ts import { LegacyEventEmitter } from 'expo-modules-core' import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types' import { AudioRecording, AudioStreamStatus, BitDepth, ConsoleLike, RecordingConfig, StartRecordingResult, } from './ExpoAudioStream.types' import { WebRecorder } from './WebRecorder.web' import { AudioEventPayload } from './events' import { encodingToBitDepth } from './utils/encodingToBitDepth' export interface EmitAudioEventProps { data: Float32Array position: number compression?: { data: Blob size: number totalSize: number mimeType: string format: string bitrate: number } } export type EmitAudioEventFunction = (_: EmitAudioEventProps) => void export type EmitAudioAnalysisFunction = (_: AudioAnalysis) => void export interface ExpoAudioStreamWebProps { logger?: ConsoleLike audioWorkletUrl: string featuresExtratorUrl: string maxBufferSize?: number // Maximum number of chunks to keep in memory } export class ExpoAudioStreamWeb extends LegacyEventEmitter { customRecorder: WebRecorder | null audioChunks: Float32Array[] isRecording: boolean isPaused: boolean recordingStartTime: number pausedTime: number currentDurationMs: number currentSize: number currentInterval: number currentIntervalAnalysis: number lastEmittedSize: number lastEmittedTime: number lastEmittedCompressionSize: number lastEmittedAnalysisTime: number streamUuid: string | null extension: 'webm' | 'wav' = 'wav' // Default extension is 'wav' recordingConfig?: RecordingConfig bitDepth: BitDepth // Bit depth of the audio audioWorkletUrl: string featuresExtratorUrl: string logger?: ConsoleLike latestPosition: number = 0 totalCompressedSize: number = 0 private readonly maxBufferSize: number constructor({ audioWorkletUrl, featuresExtratorUrl, logger, maxBufferSize = 100, // Default to storing last 100 chunks (1 chunk = 0.5 seconds) }: ExpoAudioStreamWebProps) { const mockNativeModule = { addListener: () => { // Not used on web }, removeListeners: () => { // Not used on web }, } super(mockNativeModule) // Pass the mock native module to the parent class this.logger = logger this.customRecorder = null this.audioChunks = [] this.isRecording = false this.isPaused = false this.recordingStartTime = 0 this.pausedTime = 0 this.currentDurationMs = 0 this.currentSize = 0 this.bitDepth = 32 // Default this.currentInterval = 1000 // Default interval in ms this.currentIntervalAnalysis = 500 // Default analysis interval in ms this.lastEmittedSize = 0 this.lastEmittedTime = 0 this.latestPosition = 0 this.lastEmittedCompressionSize = 0 this.lastEmittedAnalysisTime = 0 this.streamUuid = null // Initialize UUID on first recording start this.audioWorkletUrl = audioWorkletUrl this.featuresExtratorUrl = featuresExtratorUrl this.maxBufferSize = maxBufferSize } // Utility to handle user media stream async getMediaStream() { try { return await navigator.mediaDevices.getUserMedia({ audio: true }) } catch (error) { this.logger?.error('Failed to get media stream:', error) throw error } } // Start recording with options async startRecording( recordingConfig: RecordingConfig = {} ): Promise<StartRecordingResult> { if (this.isRecording) { throw new Error('Recording is already in progress') } this.bitDepth = encodingToBitDepth({ encoding: recordingConfig.encoding ?? 'pcm_32bit', }) const audioContext = new (window.AudioContext || // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - Allow webkitAudioContext for Safari window.webkitAudioContext)() const stream = await this.getMediaStream() const source = audioContext.createMediaStreamSource(stream) this.customRecorder = new WebRecorder({ logger: this.logger, audioContext, source, recordingConfig, emitAudioEventCallback: ({ data, position, compression, }: EmitAudioEventProps) => { // Keep only the latest chunks based on maxBufferSize this.audioChunks.push(new Float32Array(data)) if (this.audioChunks.length > this.maxBufferSize) { this.audioChunks.shift() // Remove oldest chunk } this.currentSize += data.byteLength this.emitAudioEvent({ data, position, compression }) this.lastEmittedTime = Date.now() this.lastEmittedSize = this.currentSize this.lastEmittedCompressionSize = compression?.size ?? 0 }, emitAudioAnalysisCallback: (audioAnalysisData: AudioAnalysis) => { this.logger?.log(`Emitted AudioAnalysis:`, audioAnalysisData) this.emit('AudioAnalysis', audioAnalysisData) }, }) await this.customRecorder.init() this.customRecorder.start() // // Set a timer to stop recording after 5 seconds // setTimeout(() => { // logger.log("AUTO Stopping recording"); // this.customRecorder?.stopAndPlay(); // this.isRecording = false; // }, 3000); this.isRecording = true this.recordingConfig = recordingConfig this.recordingStartTime = Date.now() this.pausedTime = 0 this.isPaused = false this.lastEmittedSize = 0 this.lastEmittedTime = 0 this.lastEmittedCompressionSize = 0 this.currentInterval = recordingConfig.interval ?? 1000 this.currentIntervalAnalysis = recordingConfig.intervalAnalysis ?? 500 this.lastEmittedAnalysisTime = Date.now() // Use custom filename if provided, otherwise fallback to timestamp if (recordingConfig.filename) { // Remove any existing extension from the filename this.streamUuid = recordingConfig.filename.replace(/\.[^/.]+$/, '') } else { this.streamUuid = Date.now().toString() } const fileUri = `${this.streamUuid}.${this.extension}` const streamConfig: StartRecordingResult = { fileUri, mimeType: `audio/${this.extension}`, bitDepth: this.bitDepth, channels: recordingConfig.channels ?? 1, sampleRate: recordingConfig.sampleRate ?? 44100, compression: recordingConfig.compression ? { ...recordingConfig.compression, bitrate: recordingConfig.compression?.bitrate ?? 128000, size: 0, mimeType: 'audio/webm', format: recordingConfig.compression?.format ?? 'opus', compressedFileUri: '', } : undefined, } return streamConfig } emitAudioEvent({ data, position, compression }: EmitAudioEventProps) { const fileUri = `${this.streamUuid}.${this.extension}` if (compression?.size) { this.lastEmittedCompressionSize = compression.size this.totalCompressedSize = compression.totalSize } this.latestPosition = position this.currentDurationMs = position * 1000 // Convert position (in seconds) to ms const audioEventPayload: AudioEventPayload = { fileUri, mimeType: `audio/${this.extension}`, lastEmittedSize: this.lastEmittedSize, deltaSize: data.byteLength, position, totalSize: this.currentSize, buffer: data, streamUuid: this.streamUuid ?? '', compression: compression ? { data: compression?.data, totalSize: this.totalCompressedSize, eventDataSize: compression?.size ?? 0, position, } : undefined, } this.emit('AudioData', audioEventPayload) } // Stop recording async stopRecording(): Promise<AudioRecording> { if (!this.customRecorder) { throw new Error('Recorder is not initialized') } this.logger?.debug('[Stop] Starting stop process') const startTime = performance.now() try { this.logger?.debug('[Stop] Stopping recorder') const { compressedBlob } = await this.customRecorder.stop() this.isRecording = false this.isPaused = false this.currentDurationMs = Date.now() - this.recordingStartTime let compression: AudioRecording['compression'] let fileUri = `${this.streamUuid}.${this.extension}` let mimeType = `audio/${this.extension}` if (compressedBlob && this.recordingConfig?.compression?.enabled) { const compressedUri = URL.createObjectURL(compressedBlob) compression = { compressedFileUri: compressedUri, size: compressedBlob.size, mimeType: 'audio/webm', format: 'opus', bitrate: this.recordingConfig.compression.bitrate ?? 128000, } // Use compressed values when compression is enabled fileUri = compressedUri mimeType = 'audio/webm' } this.logger?.debug( `[Stop] Completed stop process in ${performance.now() - startTime}ms`, { durationMs: this.currentDurationMs, compressedSize: compression?.size, } ) // Use the stored streamUuid (which contains our custom filename) for the final filename const filename = `${this.streamUuid}.${this.extension}` const result: AudioRecording = { fileUri, filename, // This will now use our custom filename bitDepth: this.bitDepth, createdAt: this.recordingStartTime, channels: this.recordingConfig?.channels ?? 1, sampleRate: this.recordingConfig?.sampleRate ?? 44100, durationMs: this.currentDurationMs, size: this.currentSize, mimeType, compression, } // Reset after creating the result this.streamUuid = null return result } catch (error) { this.logger?.error('[Stop] Error stopping recording:', error) throw error } } // Pause recording async pauseRecording() { if (!this.isRecording || this.isPaused) { throw new Error('Recording is not active or already paused') } if (this.customRecorder) { this.customRecorder.pause() } this.isPaused = true this.pausedTime = Date.now() } // Resume recording async resumeRecording() { if (!this.isPaused) { throw new Error('Recording is not paused') } if (this.customRecorder) { this.customRecorder.resume() } this.isPaused = false this.recordingStartTime += Date.now() - this.pausedTime } // Get current status status() { const status: AudioStreamStatus = { isRecording: this.isRecording, isPaused: this.isPaused, durationMs: this.currentDurationMs, size: this.currentSize, interval: this.currentInterval, intervalAnalysis: this.currentIntervalAnalysis, mimeType: `audio/${this.extension}`, compression: this.recordingConfig?.compression?.enabled ? { size: this.totalCompressedSize, mimeType: 'audio/webm', format: this.recordingConfig.compression.format ?? 'opus', bitrate: this.recordingConfig.compression.bitrate ?? 128000, compressedFileUri: `${this.streamUuid}.webm`, } : undefined, } return status } }