UNPKG

react-native-audio-api

Version:

react-native-audio-api provides system for controlling audio in React Native environment compatible with Web Audio API specification

237 lines (201 loc) • 6.59 kB
import { AudioApiError } from '../errors'; import { AudioDurationInput, DecodeDataInput } from '../types'; import { base64ToArrayBuffer } from '../utils'; import AudioBuffer from './AudioBuffer.web'; import OfflineAudioContext from './OfflineAudioContext.web'; const MAX_INT16_VALUE = 32768.0; export default class AudioDecoder { private static instance: AudioDecoder | null = null; // keep it a singleton pattern // eslint-disable-next-line no-useless-constructor private constructor() {} public static getInstance(): AudioDecoder { if (!AudioDecoder.instance) { AudioDecoder.instance = new AudioDecoder(); } return AudioDecoder.instance; } private async decodeAudioDataImplementation( input: DecodeDataInput, sampleRate?: number, fetchOptions?: RequestInit ): Promise<AudioBuffer | null | undefined> { const rate = sampleRate ?? 0; if (input instanceof ArrayBuffer) { return this.decodeFromArrayBuffer(input, rate); } if (typeof input !== 'string') { throw TypeError( 'Input must be either an ArrayBuffer or a valid string URL path' ); } return this.decodeFromRemoteUrl(input, rate, fetchOptions); } private async decodeFromArrayBuffer( arrayBuffer: ArrayBuffer, sampleRate: number ): Promise<AudioBuffer> { const targetRate = sampleRate > 0 ? sampleRate : 44100; const context = new OfflineAudioContext(1, 1, targetRate); return await context.decodeAudioData(arrayBuffer); } private async decodeFromRemoteUrl( url: string, sampleRate: number, fetchOptions?: RequestInit ): Promise<AudioBuffer> { const arrayBuffer = await fetch(url, fetchOptions).then((res) => res.arrayBuffer() ); return this.decodeFromArrayBuffer(arrayBuffer, sampleRate); } public async decodeAudioDataInstance( input: DecodeDataInput, sampleRate?: number, fetchOptions?: RequestInit ): Promise<AudioBuffer> { const audioBuffer = await this.decodeAudioDataImplementation( input, sampleRate, fetchOptions ); if (!audioBuffer) { throw new AudioApiError('Failed to decode audio data.'); } return audioBuffer; } public async decodePCMInBase64Instance( base64String: string, inputSampleRate: number, inputChannelCount: number, interleaved: boolean ): Promise<AudioBuffer> { try { const arrayBuffer = base64ToArrayBuffer(base64String); // map as 16 bits const int16samples = new Int16Array(arrayBuffer); const totalSampleCount = int16samples.length; const frameCount = totalSampleCount / inputChannelCount; // get requested buffer to write into const context = new OfflineAudioContext( inputChannelCount, frameCount, inputSampleRate ); const buffer = context.createBuffer( inputChannelCount, frameCount, inputSampleRate ); // deinterleave let outIndex: number; for (let channel = 0; channel < inputChannelCount; channel++) { const channelData = buffer.getChannelData(channel); for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { outIndex = interleaved ? channel + frameIndex * inputChannelCount // Ch1, Ch2, Ch1, Ch2, ... : frameIndex + channel * frameCount; // Ch1, Ch1, Ch1, ..., Ch2, Ch2, Ch2, ... // normalize channelData[frameIndex] = int16samples[outIndex] / MAX_INT16_VALUE; } } return Promise.resolve(buffer); } catch { throw new AudioApiError('Failed to decode PCM data.'); } } private loadDurationFromAudioElement(input: string): Promise<number> { if (typeof globalThis.Audio !== 'function') { return Promise.reject( new AudioApiError('getAudioDuration requires HTMLAudioElement support.') ); } return new Promise((resolve, reject) => { const audio = new globalThis.Audio(); let settled = false; const cleanup = () => { audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('error', handleError); audio.src = ''; audio.load(); }; const settle = (callback: () => void) => { if (settled) { return; } settled = true; cleanup(); callback(); }; const handleLoadedMetadata = () => { const duration = audio.duration; if (Number.isFinite(duration) && duration >= 0) { settle(() => resolve(duration)); return; } settle(() => reject(new AudioApiError('Audio duration metadata is unavailable')) ); }; const handleError = () => { const errorMessage = audio.error?.message ? `: ${audio.error.message}` : ''; settle(() => reject( new AudioApiError(`Failed to load audio metadata${errorMessage}`) ) ); }; audio.preload = 'metadata'; audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('error', handleError); audio.src = input; audio.load(); }); } public getAudioDurationInstance(input: AudioDurationInput): Promise<number> { if (input instanceof ArrayBuffer) { const blob = new Blob([input]); const objectUrl = URL.createObjectURL(blob); return this.loadDurationFromAudioElement(objectUrl).finally(() => { URL.revokeObjectURL(objectUrl); }); } if (typeof input !== 'string') { return Promise.reject( new TypeError( 'Input must be a local file path, remote URL, or ArrayBuffer.' ) ); } return this.loadDurationFromAudioElement(input); } } export async function decodeAudioData( input: DecodeDataInput, sampleRate?: number, fetchOptions?: RequestInit ): Promise<AudioBuffer> { return AudioDecoder.getInstance().decodeAudioDataInstance( input, sampleRate, fetchOptions ); } export async function decodePCMInBase64( base64String: string, inputSampleRate: number, inputChannelCount: number, isInterleaved: boolean = true ): Promise<AudioBuffer> { return AudioDecoder.getInstance().decodePCMInBase64Instance( base64String, inputSampleRate, inputChannelCount, isInterleaved ); } export function getAudioDuration(input: AudioDurationInput): Promise<number> { return AudioDecoder.getInstance().getAudioDurationInstance(input); }