diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index eb3fe7cfa..2db2bb179 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -19,6 +19,7 @@ export { default as ConstantSourceNode } from './web-core/ConstantSourceNode'; export { default as ConvolverNode } from './web-core/ConvolverNode'; export { default as PeriodicWave } from './web-core/PeriodicWave'; export { default as WaveShaperNode } from './web-core/WaveShaperNode'; +export { decodeAudioData, decodePCMInBase64 } from './web-core/AudioDecoder'; export * from './web-core/custom'; diff --git a/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx b/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx index d2b2c0e44..f2a26d256 100644 --- a/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx +++ b/packages/react-native-audio-api/src/development/react/Audio/Audio.tsx @@ -59,7 +59,7 @@ const Audio = React.forwardRef((props, ref) => { fileSourceRef.current?.dispose(); fileSourceRef.current = null; }; - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const effectiveMutedState = useMemo(() => { return mutedState ?? muted; diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index e23c88e37..d1b32e6ae 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -1,6 +1,7 @@ import AudioBuffer from './core/AudioBuffer'; import PeriodicWave from './core/PeriodicWave'; import { IAudioBuffer } from './interfaces'; +import AudioBufferWeb from './web-core/AudioBuffer'; export type Result = | ({ status: 'success' } & T) @@ -163,6 +164,13 @@ export interface AudioBufferSourceOptions extends BaseAudioBufferSourceOptions { loopEnd?: number; } +export interface AudioBufferSourceOptionsWeb extends BaseAudioBufferSourceOptions { + buffer?: AudioBufferWeb; + loop?: boolean; + loopStart?: number; + loopEnd?: number; +} + // options that are passed to c++ layer export interface IAudioBufferSourceOptions extends BaseAudioBufferSourceOptions { buffer?: IAudioBuffer; diff --git a/packages/react-native-audio-api/src/web-core/AudioBufferSourceNode.tsx b/packages/react-native-audio-api/src/web-core/AudioBufferSourceNode.tsx index a0b4551a9..fee6ac4da 100644 --- a/packages/react-native-audio-api/src/web-core/AudioBufferSourceNode.tsx +++ b/packages/react-native-audio-api/src/web-core/AudioBufferSourceNode.tsx @@ -6,8 +6,9 @@ import BaseAudioContext from './BaseAudioContext'; import AudioNode from './AudioNode'; import { clamp } from '../utils'; -import { AudioBufferSourceOptions } from '../types'; +import { AudioBufferSourceOptionsWeb } from '../types'; import { globalWasmPromise, globalTag } from './custom/LoadCustomWasm'; +import { LoadCustomWasm } from './custom'; interface ScheduleOptions { rate?: number; @@ -214,10 +215,11 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb { private _buffer: AudioBuffer | null = null; private bufferHasBeenSet: boolean = false; - constructor(context: BaseAudioContext) { + constructor(context: BaseAudioContext, options: AudioBufferSourceOptionsWeb) { const promise = async () => { + await LoadCustomWasm('/react-native-audio-api'); await globalWasmPromise; - return window[globalTag](new window.AudioContext()); + return window[globalTag](context.context); }; this.context = context; this.stretcherPromise = promise(); @@ -227,7 +229,7 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb { this.detune = new AudioParam( new IStretcherNodeAudioParam( - 0, + options.detune ?? 0, this.setDetune.bind(this), 'a-rate', -1200, @@ -239,7 +241,7 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb { this.playbackRate = new AudioParam( new IStretcherNodeAudioParam( - 1, + options.playbackRate ?? 1, this.setPlaybackRate.bind(this), 'a-rate', 0, @@ -248,6 +250,8 @@ class AudioBufferSourceNodeStretcher implements IAudioAPIBufferSourceNodeWeb { ), context ); + + this.buffer = options.buffer ?? null; } connect(destination: AudioNode | AudioParam): AudioNode | AudioParam { @@ -489,10 +493,14 @@ class AudioBufferSourceNodeWeb implements IAudioAPIBufferSourceNodeWeb { readonly playbackRate: AudioParam; readonly detune: AudioParam; - constructor(context: BaseAudioContext, options?: AudioBufferSourceOptions) { + constructor( + context: BaseAudioContext, + options?: AudioBufferSourceOptionsWeb + ) { + const { buffer, ...rest } = options ?? {}; this.node = new globalThis.AudioBufferSourceNode(context.context, { - ...options, - ...(options?.buffer ? { buffer: options.buffer.buffer } : {}), + ...rest, + ...(buffer ? { buffer: buffer.buffer } : {}), }); this.detune = new AudioParam(this.node.detune, context); this.playbackRate = new AudioParam(this.node.playbackRate, context); @@ -619,9 +627,12 @@ class AudioBufferSourceNodeWeb implements IAudioAPIBufferSourceNodeWeb { export default class AudioBufferSourceNode implements IAudioAPIBufferSourceNodeWeb { private node: AudioBufferSourceNodeStretcher | AudioBufferSourceNodeWeb; - constructor(context: BaseAudioContext, options?: AudioBufferSourceOptions) { + constructor( + context: BaseAudioContext, + options?: AudioBufferSourceOptionsWeb + ) { this.node = options?.pitchCorrection - ? new AudioBufferSourceNodeStretcher(context) + ? new AudioBufferSourceNodeStretcher(context, options) : new AudioBufferSourceNodeWeb(context, options); } diff --git a/packages/react-native-audio-api/src/web-core/AudioDecoder.tsx b/packages/react-native-audio-api/src/web-core/AudioDecoder.tsx new file mode 100644 index 000000000..0d063a3d2 --- /dev/null +++ b/packages/react-native-audio-api/src/web-core/AudioDecoder.tsx @@ -0,0 +1,150 @@ +import { AudioApiError } from '../errors'; +import { DecodeDataInput } from '../types'; +import { base64ToArrayBuffer } from '../utils'; +import { isRemoteSource } from '../utils/paths'; +import AudioBuffer from './AudioBuffer'; + +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 { + const rate = sampleRate ?? 0; + + if (input instanceof ArrayBuffer) { + return this.decodeFromArrayBuffer(input, rate); + } + + const isUri = typeof input === 'string' && isRemoteSource(input); + if (!isUri) { + throw new TypeError('Input must be a an uri or ArrayBuffer'); + } + + return this.decodeFromRemoteUrl(input, rate, fetchOptions); + } + + private async decodeFromArrayBuffer( + arrayBuffer: ArrayBuffer, + sampleRate: number + ): Promise { + const targetRate = sampleRate > 0 ? sampleRate : 44100; + const context = new OfflineAudioContext(1, 1, targetRate); + return new AudioBuffer(await context.decodeAudioData(arrayBuffer)); + } + + private async decodeFromRemoteUrl( + url: string, + sampleRate: number, + fetchOptions?: RequestInit + ): Promise { + 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 { + 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 { + 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(new AudioBuffer(buffer)); + } catch { + throw new AudioApiError('Failed to decode PCM data.'); + } + } +} + +export async function decodeAudioData( + input: DecodeDataInput, + sampleRate?: number, + fetchOptions?: RequestInit +): Promise { + return AudioDecoder.getInstance().decodeAudioDataInstance( + input, + sampleRate, + fetchOptions + ); +} + +export async function decodePCMInBase64( + base64String: string, + inputSampleRate: number, + inputChannelCount: number, + isInterleaved: boolean = true +): Promise { + return AudioDecoder.getInstance().decodePCMInBase64Instance( + base64String, + inputSampleRate, + inputChannelCount, + isInterleaved + ); +}