diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 1addb6681..fcac79ab7 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -10,8 +10,10 @@ import { Button, Container, Spacer } from '../../components'; import { colors } from '../../styles'; import AudioPlayer from './AudioPlayer'; -const URL = - 'https://software-mansion.github.io/react-native-audio-api/audio/voice/example-voice-01.mp3'; +// const remoteAsset = +// 'https://software-mansion.github.io/react-native-audio-api/audio/voice/example-voice-01.mp3'; + +import staticAsset from './voice-sample-landing.mp3'; const AudioFile: FC = () => { const [isPlaying, setIsPlaying] = useState(false); @@ -56,7 +58,7 @@ const AudioFile: FC = () => { const fetchAudioBuffer = useCallback(async () => { setIsLoading(true); - await AudioPlayer.loadBuffer(URL); + await AudioPlayer.loadBuffer(staticAsset); setIsLoading(false); }, []); @@ -83,7 +85,7 @@ const AudioFile: FC = () => { const setup = async () => { await fetchAudioBuffer(); await setupNotification(); - } + }; setup(); return () => { AudioPlayer.reset(); @@ -92,7 +94,6 @@ const AudioFile: FC = () => { }, [fetchAudioBuffer]); useEffect(() => { - AudioManager.observeAudioInterruptions(true); // Listen to notification control events diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index e285d4322..5441e86c3 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -7,7 +7,6 @@ import { decodeAudioData, PlaybackNotificationManager, } from 'react-native-audio-api'; -import { Image } from 'react-native'; class AudioPlayer { private readonly audioContext: AudioContext; @@ -38,7 +37,7 @@ class AudioPlayer { this.isPlaying = true; PlaybackNotificationManager.update({ state: 'playing', - }) + }); if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); @@ -55,11 +54,16 @@ class AudioPlayer { this.sourceNode.onPositionChanged = (event) => { this.currentElapsedTime = event.value; if (this.onPositionChanged) { - this.onPositionChanged(this.currentElapsedTime / this.audioBuffer!.duration); + this.onPositionChanged( + this.currentElapsedTime / this.audioBuffer!.duration + ); } }; - this.sourceNode.start(this.audioContext.currentTime, this.currentElapsedTime); + this.sourceNode.start( + this.audioContext.currentTime, + this.currentElapsedTime + ); }; pause = async () => { @@ -73,7 +77,7 @@ class AudioPlayer { await this.audioContext.suspend(); PlaybackNotificationManager.update({ state: 'paused', - }) + }); this.isPlaying = false; }; @@ -96,8 +100,13 @@ class AudioPlayer { } }; - loadBuffer = async (url: string) => { - const buffer = await decodeAudioData(url); + loadBuffer = async (asset: string | number) => { + const buffer = await decodeAudioData(asset, 0, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Android; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0', + }, + }); if (buffer) { this.audioBuffer = buffer; @@ -132,7 +141,7 @@ class AudioPlayer { getElapsedTime = (): number => { return this.currentElapsedTime; - } + }; } export default new AudioPlayer(); diff --git a/apps/common-app/src/examples/AudioFile/voice-sample-landing.mp3 b/apps/common-app/src/examples/AudioFile/voice-sample-landing.mp3 new file mode 100644 index 000000000..11bd9e60d Binary files /dev/null and b/apps/common-app/src/examples/AudioFile/voice-sample-landing.mp3 differ diff --git a/apps/common-app/types.d.ts b/apps/common-app/types.d.ts new file mode 100644 index 000000000..e61eb2f3c --- /dev/null +++ b/apps/common-app/types.d.ts @@ -0,0 +1 @@ +module '*.mp3'; diff --git a/packages/react-native-audio-api/src/core/AudioDecoder.ts b/packages/react-native-audio-api/src/core/AudioDecoder.ts index d11018619..86c524a05 100644 --- a/packages/react-native-audio-api/src/core/AudioDecoder.ts +++ b/packages/react-native-audio-api/src/core/AudioDecoder.ts @@ -1,4 +1,12 @@ +import { Image } from 'react-native'; + import { IAudioDecoder } from '../interfaces'; +import { DecodeDataInput } from '../types'; +import { + isBase64Source, + isDataBlobString, + isRemoteSource, +} from '../utils/paths'; import AudioBuffer from './AudioBuffer'; class AudioDecoder { @@ -9,50 +17,89 @@ class AudioDecoder { this.decoder = global.createAudioDecoder(); } + private async decodeAudioDataImplementation( + input: DecodeDataInput, + sampleRate?: number, + fetchOptions?: RequestInit + ): Promise { + if (input instanceof ArrayBuffer) { + const buffer = await this.decoder.decodeWithMemoryBlock( + new Uint8Array(input), + sampleRate ?? 0 + ); + return new AudioBuffer(buffer); + } + + const stringSource = + typeof input === 'number' ? Image.resolveAssetSource(input).uri : input; + + // input is data:audio/...;base64,... + if (isBase64Source(stringSource)) { + throw new Error( + 'Base64 source decoding is not currently supported, to decode raw PCM base64 strings use decodePCMInBase64 method.' + ); + } + + // input is blob:... + if (isDataBlobString(stringSource)) { + throw new Error('Data Blob string decoding is not currently supported.'); + } + + // input is http(s)://... + if (isRemoteSource(stringSource)) { + const arrayBuffer = await fetch(stringSource, fetchOptions).then((res) => + res.arrayBuffer() + ); + + const buffer = await this.decoder.decodeWithMemoryBlock( + new Uint8Array(arrayBuffer), + sampleRate ?? 0 + ); + + return new AudioBuffer(buffer); + } + + if (!(typeof input === 'string')) { + throw new TypeError('Input must be a module, uri or ArrayBuffer'); + } + + // Local file path + const filePath = stringSource.startsWith('file://') + ? stringSource.replace('file://', '') + : stringSource; + + const buffer = await this.decoder.decodeWithFilePath( + filePath, + sampleRate ?? 0 + ); + + return new AudioBuffer(buffer); + } + public static getInstance(): AudioDecoder { if (!AudioDecoder.instance) { AudioDecoder.instance = new AudioDecoder(); } + return AudioDecoder.instance; } public async decodeAudioDataInstance( - input: string | ArrayBuffer, - sampleRate?: number + input: DecodeDataInput, + sampleRate?: number, + fetchOptions?: RequestInit ): Promise { - let buffer; - if (typeof input === 'string') { - // Remove the file:// prefix if it exists - if (input.startsWith('file://')) { - input = input.replace('file://', ''); - } else if (input.startsWith('https://') || input.startsWith('http://')) { - // For remote URLs, we need to fetch the data first - const response = await fetch(input, { - headers: { - 'User-Agent': - 'Mozilla/5.0 (Android; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0', - }, - }) - .then((res) => res.arrayBuffer()) - .then((arrayBuffer) => arrayBuffer); - buffer = await this.decoder.decodeWithMemoryBlock( - new Uint8Array(response), - sampleRate ?? 0 - ); - } else { - buffer = await this.decoder.decodeWithFilePath(input, sampleRate ?? 0); - } - } else if (input instanceof ArrayBuffer) { - buffer = await this.decoder.decodeWithMemoryBlock( - new Uint8Array(input), - sampleRate ?? 0 - ); - } + const audioBuffer = await this.decodeAudioDataImplementation( + input, + sampleRate, + fetchOptions + ); - if (!buffer) { - throw new Error('Unsupported input type or failed to decode audio'); + if (!audioBuffer) { + throw new Error('Failed to decode audio data.'); } - return new AudioBuffer(buffer); + + return audioBuffer; } public async decodePCMInBase64Instance( @@ -72,10 +119,15 @@ class AudioDecoder { } export async function decodeAudioData( - input: string | ArrayBuffer, - sampleRate?: number + input: DecodeDataInput, + sampleRate?: number, + fetchOptions?: RequestInit ): Promise { - return AudioDecoder.getInstance().decodeAudioDataInstance(input, sampleRate); + return AudioDecoder.getInstance().decodeAudioDataInstance( + input, + sampleRate, + fetchOptions + ); } export async function decodePCMInBase64( diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index 1718414e2..84353ad51 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -10,6 +10,7 @@ import { AudioWorkletRuntime, ContextState, ConvolverNodeOptions, + DecodeDataInput, IIRFilterNodeOptions, PeriodicWaveConstraints, } from '../types'; @@ -56,13 +57,10 @@ export default class BaseAudioContext { } public async decodeAudioData( - input: string | ArrayBuffer, - sampleRate?: number + input: DecodeDataInput, + fetchOptions?: RequestInit ): Promise { - if (!(typeof input === 'string' || input instanceof ArrayBuffer)) { - throw new TypeError('Input must be a string or ArrayBuffer'); - } - return await decodeAudioData(input, sampleRate ?? this.sampleRate); + return await decodeAudioData(input, this.sampleRate, fetchOptions); } public async decodePCMInBase64( diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 550d14c5a..1a6aecec9 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -149,3 +149,5 @@ export interface IIRFilterNodeOptions { feedforward: number[]; feedback: number[]; } + +export type DecodeDataInput = number | string | ArrayBuffer; diff --git a/packages/react-native-audio-api/src/utils/paths.ts b/packages/react-native-audio-api/src/utils/paths.ts new file mode 100644 index 000000000..1d46adab0 --- /dev/null +++ b/packages/react-native-audio-api/src/utils/paths.ts @@ -0,0 +1,11 @@ +export function isRemoteSource(url: string): boolean { + return url.startsWith('http://') || url.startsWith('https://'); +} + +export function isBase64Source(data: string): boolean { + return data.startsWith('data:audio/') && data.includes(';base64,'); +} + +export function isDataBlobString(data: string): boolean { + return data.startsWith('blob:'); +} diff --git a/packages/react-native-audio-api/src/web-core/AudioContext.tsx b/packages/react-native-audio-api/src/web-core/AudioContext.tsx index ed800f75b..12ed194f2 100644 --- a/packages/react-native-audio-api/src/web-core/AudioContext.tsx +++ b/packages/react-native-audio-api/src/web-core/AudioContext.tsx @@ -1,28 +1,29 @@ +import { InvalidAccessError, NotSupportedError } from '../errors'; import { - ContextState, - PeriodicWaveConstraints, - AudioContextOptions, AudioBufferBaseSourceNodeOptions, + AudioContextOptions, + ContextState, + DecodeDataInput, IIRFilterNodeOptions, + PeriodicWaveConstraints, } from '../types'; -import { InvalidAccessError, NotSupportedError } from '../errors'; -import BaseAudioContext from './BaseAudioContext'; import AnalyserNode from './AnalyserNode'; -import AudioDestinationNode from './AudioDestinationNode'; import AudioBuffer from './AudioBuffer'; import AudioBufferSourceNode from './AudioBufferSourceNode'; +import AudioDestinationNode from './AudioDestinationNode'; +import BaseAudioContext from './BaseAudioContext'; import BiquadFilterNode from './BiquadFilterNode'; -import IIRFilterNode from './IIRFilterNode'; +import ConvolverNode from './ConvolverNode'; +import { ConvolverNodeOptions } from './ConvolverNodeOptions'; +import DelayNode from './DelayNode'; import GainNode from './GainNode'; +import IIRFilterNode from './IIRFilterNode'; import OscillatorNode from './OscillatorNode'; import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; -import ConvolverNode from './ConvolverNode'; -import DelayNode from './DelayNode'; -import { ConvolverNodeOptions } from './ConvolverNodeOptions'; -import { globalWasmPromise, globalTag } from './custom/LoadCustomWasm'; import ConstantSourceNode from './ConstantSourceNode'; +import { globalTag, globalWasmPromise } from './custom/LoadCustomWasm'; import WaveShaperNode from './WaveShaperNode'; export default class AudioContext implements BaseAudioContext { @@ -180,16 +181,30 @@ export default class AudioContext implements BaseAudioContext { return new WaveShaperNode(this, this.context.createWaveShaper()); } - async decodeAudioDataSource(source: string): Promise { - const arrayBuffer = await fetch(source).then((response) => - response.arrayBuffer() - ); + async decodeAudioData( + source: DecodeDataInput, + fetchOptions?: RequestInit + ): Promise { + if (source instanceof ArrayBuffer) { + const decodedData = await this.context.decodeAudioData(source); + return new AudioBuffer(decodedData); + } - return this.decodeAudioData(arrayBuffer); - } + if (typeof source === 'string') { + const response = await fetch(source, fetchOptions); + + if (!response.ok) { + throw new InvalidAccessError( + `Failed to fetch audio data from the provided source: ${source}` + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const decodedData = await this.context.decodeAudioData(arrayBuffer); + return new AudioBuffer(decodedData); + } - async decodeAudioData(arrayBuffer: ArrayBuffer): Promise { - return new AudioBuffer(await this.context.decodeAudioData(arrayBuffer)); + throw new TypeError('Unsupported source for decodeAudioData: ' + source); } async close(): Promise { diff --git a/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx b/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx index d94b2be64..96d15cb8b 100644 --- a/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx +++ b/packages/react-native-audio-api/src/web-core/BaseAudioContext.tsx @@ -1,21 +1,21 @@ import { ContextState, - PeriodicWaveConstraints, IIRFilterNodeOptions, + PeriodicWaveConstraints, } from '../types'; import AnalyserNode from './AnalyserNode'; -import AudioDestinationNode from './AudioDestinationNode'; import AudioBuffer from './AudioBuffer'; import AudioBufferSourceNode from './AudioBufferSourceNode'; +import AudioDestinationNode from './AudioDestinationNode'; import BiquadFilterNode from './BiquadFilterNode'; +import ConstantSourceNode from './ConstantSourceNode'; +import ConvolverNode from './ConvolverNode'; import DelayNode from './DelayNode'; -import IIRFilterNode from './IIRFilterNode'; import GainNode from './GainNode'; +import IIRFilterNode from './IIRFilterNode'; import OscillatorNode from './OscillatorNode'; import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; -import ConstantSourceNode from './ConstantSourceNode'; -import ConvolverNode from './ConvolverNode'; import WaveShaperNode from './WaveShaperNode'; export default interface BaseAudioContext { @@ -47,6 +47,8 @@ export default interface BaseAudioContext { ): PeriodicWave; createAnalyser(): AnalyserNode; createWaveShaper(): WaveShaperNode; - decodeAudioDataSource(source: string): Promise; - decodeAudioData(arrayBuffer: ArrayBuffer): Promise; + decodeAudioData( + arrayBuffer: ArrayBuffer, + fetchOptions?: RequestInit + ): Promise; }